"""
Agent Orchestrator

Manages Claude Code containers for all RoboCo agents.
Handles spawning, monitoring, health checks, and graceful shutdown.

The orchestrator is the BRAIN of the system:
- Checks for work BEFORE spawning agents (no wasteful spawns)
- Claims tasks on behalf of agents before spawning
- Agents receive their assignment at spawn time
- Agents scan for more work after completing a task
- Agents only call roboco_agent_idle() when truly no work remains
"""

import asyncio
import contextlib
import json
import os
import shutil
import tempfile
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar

import httpx

if TYPE_CHECKING:
    from roboco.services.llm import AgentRoute
    from roboco.services.task import TaskService
import structlog
from fastapi import status as http_status

from roboco.agents.factories._base import compose_prompt
from roboco.agents_config import (
    ALL_DOCS,
    get_agent_role,
    get_agent_team,
    get_escalation_target,
)
from roboco.config import settings
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.agent_loop import DEFAULT_BUDGET as _AGENT_LOOP_BUDGET
from roboco.models import AgentRole, Team
from roboco.models.runtime import (
    MODEL_MAP,
    ROLE_MODEL_MAP,
    AgentInstance,
    OrchestratorAgentConfig,
    OrchestratorAgentState,
    SpawnGitContext,
    WaitingRecord,
)
from roboco.seeds.initial_data import AGENT_UUIDS

logger = structlog.get_logger()

# Reverse mapping: UUID -> slug
UUID_TO_SLUG = {uuid: slug for slug, uuid in AGENT_UUIDS.items()}

# Re-export for backwards compatibility
AgentState = OrchestratorAgentState
AgentConfig = OrchestratorAgentConfig

# Docker configuration
AGENT_NETWORK = "roboco_default"
AGENT_BASE_IMAGE = "roboco-agent-base"

# Role -> Image mapping
# Specialized images extend the base with role-specific tools
AGENT_IMAGES: dict[str, str] = {
    # Backend
    "be-dev-1": "roboco-agent-dev-be",
    "be-dev-2": "roboco-agent-dev-be",
    "be-qa": "roboco-agent-qa-be",
    "be-pm": "roboco-agent-pm",
    "be-doc": "roboco-agent-doc",
    # Frontend
    "fe-dev-1": "roboco-agent-dev-fe",
    "fe-dev-2": "roboco-agent-dev-fe",
    "fe-qa": "roboco-agent-qa-fe",
    "fe-pm": "roboco-agent-pm",
    "fe-doc": "roboco-agent-doc",
    # UX/UI
    "ux-dev-1": "roboco-agent-ux",
    "ux-dev-2": "roboco-agent-ux",
    "ux-qa": "roboco-agent-ux",  # Uses same as dev for now
    "ux-pm": "roboco-agent-pm",
    "ux-doc": "roboco-agent-doc",
    # Board
    "main-pm": "roboco-agent-pm",
    "product-owner": "roboco-agent-pm",
    "head-marketing": "roboco-agent-pm",
    "auditor": "roboco-agent-pm",
}

# Complete list of MCP tools that trigger traceability reminders.
# Post-gateway: every state-changing verb agents call routes through
# roboco-flow (intent verbs) or roboco-do (content tools). Read-only git
# views (roboco-git-readonly) and KB queries (roboco-optimal) emit one
# additional trigger because they are the inputs PMs/devs cite when they
# justify their next move.
TRACEABILITY_TRIGGER_TOOLS: list[str] = [
    # === Intent verbs (all role-scoped lifecycle transitions) ===
    "mcp__roboco-flow__*",
    # === Content tools (commit/push/PR + journal/notify/message) ===
    "mcp__roboco-do__*",
    # === KB Tools ===
    "mcp__roboco-optimal__roboco_ask_mentor",
]


def get_agent_image(agent_id: str) -> str:
    """Get the Docker image for an agent."""
    return AGENT_IMAGES.get(agent_id, AGENT_BASE_IMAGE)


# When running in a container, we need host paths for volume mounts.
# These can be overridden via environment variables.
CLAUDE_AUTH_HOST_PATH = os.environ.get(
    "ROBOCO_HOST_CLAUDE_DIR",
    str(Path.home() / ".claude"),
)
PROJECT_HOST_PATH = os.environ.get("ROBOCO_HOST_PROJECT_DIR", "")
DATA_HOST_PATH = os.environ.get("ROBOCO_HOST_DATA_DIR", "")


# =============================================================================
# ORCHESTRATOR
# =============================================================================


@dataclass(frozen=True)
class _SlaBreach:
    """Per-(role, state) SLA breach payload for _escalate_sla_breach."""

    task_id: str
    role: str
    status: str
    age_seconds: int
    sla_seconds: int


def _read_project_slug(task: dict[str, Any]) -> str | None:
    """Extract project slug from a task payload shape-tolerantly."""
    slug = task.get("project_slug")
    if slug:
        return str(slug)
    project = task.get("project") or {}
    inner = project.get("slug") if isinstance(project, dict) else None
    return str(inner) if inner else None


def _resolve_agent_cli_model(provider_type: str, model: str) -> str:
    """Translate an agent model name to the string Claude Code expects.

    For the Anthropic provider, short names (``opus|sonnet|haiku``) are
    translated through ``MODEL_MAP`` as they always were.  For non-Anthropic
    providers (currently Ollama Cloud) the model identifier is passed verbatim
    so raw tags like ``kimi-k2.6:cloud`` reach the Ollama-side integration
    intact.

    Extracted as a module-level function so both the ``--model`` CLI arg
    builder and the ``CLAUDE_CODE_SUBAGENT_MODEL`` env-var injector can call
    the same logic without referencing the class by name inside a staticmethod.
    """
    if provider_type == "anthropic":
        return MODEL_MAP.get(model, model)
    return model


def _agent_workspace_path(project_slug: str, team: str, agent_id: str) -> str:
    """Per-agent workspace path inside the container.

    Mirrors the bind-mount layout: the host's workspaces dir is mounted at
    /data/workspaces (orchestrator.py mount args), so each agent's clone lives
    at /data/workspaces/<project>/<team>/<agent>. Used by both
    _get_role_permissions (Edit/Write allowlist) and _build_mount_args
    (docker ``-w`` flag) so the cwd matches the allowlist scope.
    """
    return f"/data/workspaces/{project_slug}/{team}/{agent_id}"


def _cell_workspace_path(project_slug: str, team: str) -> str:
    """Cell-level workspace path (documenter scope).

    Same rationale as ``_agent_workspace_path``; documenters work at the cell
    branch, not a per-agent dev branch.
    """
    return f"/data/workspaces/{project_slug}/{team}"


def _resolve_project_slug_from_git_context(
    git_context: "SpawnGitContext | None",
) -> str:
    """Extract project_slug from git_context, falling back to 'default'.

    Module-level counterpart to the instance method ``_resolve_project_slug``.
    Called by static / classmethod contexts (e.g. ``_build_mount_args``) that
    cannot access ``self``. The fallback warning is omitted here because the
    instance method already logs it when the full spawn path runs; this helper
    is only for the mount-args path where the agent_id/task_id context is not
    available.
    """
    if git_context and git_context.project_slug:
        return git_context.project_slug
    return "default"


# =============================================================================
# SPAWN MANIFEST — per-developer tool manifest mounting (Phase 1)
# =============================================================================

# Phase 4: every role gets a gateway manifest. The legacy briefing path is gone.
GATEWAY_ENABLED_ROLES: frozenset[str] = frozenset(
    {
        "developer",
        "qa",
        "documenter",
        "cell_pm",
        "main_pm",
        "product_owner",
        "head_marketing",
        "auditor",
    }
)


def _build_manifest_for_agent(agent_id: str, model: str) -> Path | None:
    """Write a SpawnManifest for developer-role agents; return the host path.

    Returns ``None`` for roles outside ``GATEWAY_ENABLED_ROLES`` so callers
    can skip the manifest mount entirely without extra branching.

    Args:
        agent_id: Agent slug (e.g. ``be-dev-1``).
        model:    Resolved model name passed to ``SpawnInputs.agent_model``.

    Returns:
        Absolute host path to the written JSON file, or ``None``.
    """
    from uuid import UUID

    from roboco.runtime.spawn_manifest import (
        SpawnInputs,
        build_for_role,
        write_manifest,
    )

    role = get_agent_role(agent_id) or "developer"
    if role not in GATEWAY_ENABLED_ROLES:
        return None

    team = get_agent_team(agent_id) or "backend"
    # UUID for the agent comes from the seeded AGENT_UUIDS map (slug -> UUID
    # string).  Fall back to uuid4 for unknown agents so the function stays
    # callable in tests without seeded data.
    raw_uuid = AGENT_UUIDS.get(agent_id)
    agent_uuid = UUID(raw_uuid) if raw_uuid else __import__("uuid").uuid4()

    workspace_path = Path(settings.workspaces_root) / "roboco" / team / agent_id

    manifest = build_for_role(
        SpawnInputs(
            agent_id=agent_uuid,
            role=role,
            team=team,
            workspace_path=workspace_path,
            agent_model=model,
        )
    )

    # Two paths in play:
    #   - orchestrator-internal: where the file is written inside the
    #     orchestrator container (settings.manifest_host_dir). The compose
    #     volume mount makes this dir visible on the host.
    #   - host-side: what the docker daemon needs for the bind-mount into
    #     the spawned agent. Computed via DATA_HOST_PATH translation.
    write_dir = Path(settings.manifest_host_dir)
    write_path = write_dir / f"{agent_id}.json"
    write_manifest(manifest, write_path)
    if DATA_HOST_PATH:
        return Path(f"{DATA_HOST_PATH}/manifests/{agent_id}.json")
    return write_path


# =============================================================================
# GATEWAY PRE-SPAWN CHECK (gated behind settings.gateway_enabled)
# =============================================================================


async def _count_recent_spawns_for_task(
    db_session: Any,
    task_id: Any,
    cutoff: datetime,
) -> int:
    """Count recent SPAWN decisions for ``task_id`` since ``cutoff``."""
    from sqlalchemy import select

    from roboco.db.tables import GatewayTriggerTable

    result = await db_session.execute(
        select(GatewayTriggerTable).where(
            GatewayTriggerTable.task_id == task_id,
            GatewayTriggerTable.created_at >= cutoff,
            GatewayTriggerTable.decision == "spawn",
        )
    )
    return len(result.scalars().all())


async def _count_recent_spawns_for_role(
    db_session: Any,
    target_role: str,
    cutoff: datetime,
) -> int:
    """Count recent SPAWN decisions for ``target_role`` since ``cutoff``."""
    from sqlalchemy import select

    from roboco.db.tables import GatewayTriggerTable

    result = await db_session.execute(
        select(GatewayTriggerTable).where(
            GatewayTriggerTable.target_role == target_role,
            GatewayTriggerTable.created_at >= cutoff,
            GatewayTriggerTable.decision == "spawn",
        )
    )
    return len(result.scalars().all())


async def _record_trigger_decision(
    db_session: Any,
    task_id: Any,
    trigger_kind: str,
    target_role: str,
    decision: Any,
) -> None:
    """Persist a gateway trigger decision row."""
    from uuid import uuid4 as _uuid4

    from roboco.db.tables import GatewayTriggerTable

    row = GatewayTriggerTable(
        id=_uuid4(),
        trigger_kind=trigger_kind,
        task_id=task_id,
        target_role=target_role,
        decision=decision.outcome.value,
        decision_reason=decision.reason,
    )
    db_session.add(row)
    await db_session.flush()


async def gateway_pre_spawn_check(
    *,
    task_id: str | None,
    trigger_kind: str,
    target_role: str,
) -> tuple[str, str]:
    """Consult trigger_filter before spawning a container.

    Returns a ``(outcome, reason)`` tuple where ``outcome`` is one of
    ``"spawn"``, ``"queue"``, or ``"drop"``.

    When ``settings.gateway_enabled`` is False (the default in Phase 0) this
    function returns immediately with ``("spawn", "gateway disabled")`` so the
    existing legacy dispatch path is **completely unchanged**.
    """
    if not settings.gateway_enabled:
        return "spawn", "gateway disabled (legacy path)"

    from roboco.db.base import get_session_factory
    from roboco.services.gateway.trigger_filter import (
        Decision,
        SpawnConfig,
        SpawnDecision,
        TriggerContext,
        TriggerKind,
        decide_spawn,
    )

    cutoff = datetime.now(tz=UTC) - timedelta(seconds=settings.spawn_cooldown_seconds)
    role_cutoff = datetime.now(tz=UTC) - timedelta(seconds=60)

    # When no task_id we cannot query counts; allow (no-task spawns like idle PMs).
    if task_id is None:
        return SpawnDecision.SPAWN, "no task_id — no-task spawn, skip gate"

    try:
        from sqlalchemy import select as _select

        from roboco.db.tables import TaskTable as _TaskTable

        factory = get_session_factory()
        async with factory() as db:
            recent_for_task = await _count_recent_spawns_for_task(db, task_id, cutoff)
            recent_for_role = await _count_recent_spawns_for_role(
                db, target_role, role_cutoff
            )

            # Load the lightweight task proxy needed by is_stale / decide_spawn.
            task_result = await db.execute(
                _select(_TaskTable).where(_TaskTable.id == task_id)
            )
            task_row = task_result.scalars().first()

            if task_row is None:
                return SpawnDecision.SPAWN, "task not found in DB — allow by default"

            trigger = TriggerContext(
                kind=TriggerKind(trigger_kind),
                skill=None,
                recent_spawns_for_task=recent_for_task,
                recent_spawns_for_role=recent_for_role,
            )
            config = SpawnConfig(
                cooldown_seconds=settings.spawn_cooldown_seconds,
                role_rate_per_minute=settings.role_spawn_rate_per_minute,
                claim_stale_seconds=settings.claim_stale_seconds,
            )
            decision: Decision = decide_spawn(
                task=task_row, trigger=trigger, config=config
            )

            await _record_trigger_decision(
                db, task_id, trigger_kind, target_role, decision
            )
            await db.commit()

        return decision.outcome.value, decision.reason

    except Exception as exc:
        # Gateway errors must never block a spawn — degrade gracefully.
        logger.warning(
            "Gateway pre-spawn check failed; defaulting to spawn",
            task_id=task_id,
            trigger_kind=trigger_kind,
            error=str(exc),
        )
        return "spawn", f"gateway error (degraded): {exc}"


class AgentReadinessError(Exception):
    """Raised when spawn_agent refuses to spawn because the task isn't ready.

    The pre-flight gate auto-blocks the offending task before raising, so the
    dispatcher doesn't keep retrying. Callers should log and move on.
    """


class AgentOrchestrator:
    """
    Manages Claude Code containers for all agents.

    Responsibilities:
    - Spawn agents as Docker containers
    - Monitor health via docker inspect
    - Handle waiting states and respawning
    - Provide status API
    - Cost-efficient on-demand spawning
    """

    def __init__(
        self,
        blueprints_dir: Path | None = None,
        mcp_config_dir: Path | None = None,
        project_root: Path | None = None,
        dispatcher_interval: int = 30,
    ):
        self.blueprints_dir = blueprints_dir or Path("agents/blueprints")
        self.mcp_config_dir = mcp_config_dir or Path(".mcp")
        self.project_root = project_root or Path.cwd()
        self.dispatcher_interval = dispatcher_interval

        self._instances: dict[str, AgentInstance] = {}
        self._waiting_records: dict[str, WaitingRecord] = {}
        self._health_task: asyncio.Task | None = None
        self._dispatcher_task: asyncio.Task | None = None
        self._sweeper_task: asyncio.Task | None = None
        # Strong refs for fire-and-forget audit writes. Without this, the
        # event loop only weak-refs the Task and may GC it before it
        # commits — audit_log was silently empty because of this.
        self._bg_tasks: set[asyncio.Task[None]] = set()
        # Wake-up signal for the dispatcher. Set() by API routes immediately
        # after status transitions so the dispatcher reacts in milliseconds
        # instead of waiting for the next 30-second tick.
        self._dispatch_wake: asyncio.Event = asyncio.Event()
        self._running = False
        self._lock = asyncio.Lock()
        # Per-tick set of task_ids already handled by an earlier
        # dispatcher. Reset at the start of every _dispatch_all_work.
        # Consumed via `self._mark_task_handled` / `_is_task_handled`.
        self._tick_handled_tasks: set[str] = set()
        # Respawn circuit breaker: per (agent_slug, task_id), tracks how
        # many times we've spawned without the task status changing. A PM
        # that gets re-spawned on the same pending task with no progress
        # is in a loop — without this gate the orchestrator re-spawns every
        # tick forever (seen in production on 2026-04-22).
        self._pm_respawn_tracker: dict[tuple[str, str], dict[str, Any]] = {}
        # Stale-claim reaper config. Wave C3 (2026-05-12): sourced from
        # stale_claim_reap_seconds (default 600) rather than
        # claim_stale_seconds (default 180). The two settings are now
        # distinct: claim_stale_seconds drives trigger_filter (spawn
        # queueing); stale_claim_reap_seconds drives the reaper.
        # Smoke run 3 showed agents reaped at 180s while actively retrying
        # rejected verbs — LLM inference routinely exceeds that window.
        # Tests bypass `__init__` via `__new__` and set _claim_heartbeat_ttl
        # directly; production never uses _task_svc from __init__.
        self._claim_heartbeat_ttl: int = settings.stale_claim_reap_seconds

    # =========================================================================
    # LIFECYCLE
    # =========================================================================

    async def start(self) -> None:
        """Start the orchestrator."""
        self._running = True

        # Ensure agent image is built
        await self._ensure_agent_image()

        # Restore any WaitingRecord rows left by a prior orchestrator run so
        # agents that were WAITING_LONG at shutdown can still be resolved.
        await self.restore_waiting_records()

        # Self-heal: roll back orphan claims left over from a prior crash
        # (audit P2-8). Tasks that show CLAIMED/IN_PROGRESS but have NO
        # branch_name set indicate _finalize_claim flushed the status before
        # branch creation failed in a pre-P0-7 run. Without this, the next
        # claim attempt fails non-idempotent on `git checkout -b`.
        await self._reconcile_orphan_claims_on_startup()

        # Note: Per-agent settings are now generated at spawn time
        # via _generate_agent_settings() - no shared settings needed

        # Start background tasks
        self._health_task = asyncio.create_task(self._health_loop())
        self._dispatcher_task = asyncio.create_task(self._dispatcher_loop())
        self._sweeper_task = asyncio.create_task(self._sweeper_loop())

        logger.info(
            "Orchestrator started",
            dispatcher_interval=self.dispatcher_interval,
            internal_api_url=self._api_url,
        )

    async def stop(self) -> None:
        """Stop the orchestrator and all agents."""
        self._running = False

        # Cancel background tasks
        if self._health_task:
            self._health_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._health_task

        if self._dispatcher_task:
            self._dispatcher_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._dispatcher_task

        if self._sweeper_task:
            self._sweeper_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._sweeper_task

        # Stop all agents
        for agent_id in list(self._instances.keys()):
            await self.stop_agent(agent_id)

        logger.info("Orchestrator stopped")

    def get_running_agents(self) -> set[str]:
        """Get set of currently running agent IDs."""
        return set(self._instances.keys())

    def is_agent_busy(self, agent_id: str) -> bool:
        """
        Check if agent has active work.

        An agent is busy if:
        1. They're in the running instances, AND
        2. They have a task claimed/in_progress/verifying

        Note: This is a lightweight check based on instance status.
        For full busy detection, the event handler queries the database.
        """
        if agent_id not in self._instances:
            return False
        instance = self._instances[agent_id]
        # If agent is running with a task, they're busy
        return instance.current_task_id is not None

    def queue_priority_work(self, agent_id: str, work: dict[str, Any]) -> None:
        """
        Queue priority work for an agent.

        This is a placeholder for future priority queue functionality.
        Currently logs the request for observability.
        """
        logger.info(
            "Priority work queued (not yet implemented)",
            agent_id=agent_id,
            work_type=work.get("type", "unknown"),
        )

    async def _ensure_agent_image(self, agent_id: str | None = None) -> None:
        """Ensure the agent Docker images are built.

        Builds base image first, then specialized image if agent_id provided.
        """
        # Determine build context
        if PROJECT_HOST_PATH:
            build_context = PROJECT_HOST_PATH
            docker_dir = f"{PROJECT_HOST_PATH}/docker"
        else:
            build_context = str(self.project_root)
            docker_dir = str(self.project_root / "docker")

        # Always ensure base image exists
        await self._build_image_if_missing(
            AGENT_BASE_IMAGE,
            f"{docker_dir}/agent-base.Dockerfile",
            build_context,
        )

        # Build specialized image if agent specified
        if agent_id:
            image = get_agent_image(agent_id)
            if image != AGENT_BASE_IMAGE:
                # Map image name to dockerfile
                dockerfile_map = {
                    "roboco-agent-pm": "agent-pm.Dockerfile",
                    "roboco-agent-dev-be": "agent-dev-be.Dockerfile",
                    "roboco-agent-dev-fe": "agent-dev-fe.Dockerfile",
                    "roboco-agent-qa-be": "agent-qa-be.Dockerfile",
                    "roboco-agent-qa-fe": "agent-qa-fe.Dockerfile",
                    "roboco-agent-doc": "agent-doc.Dockerfile",
                    "roboco-agent-ux": "agent-ux.Dockerfile",
                }
                dockerfile = dockerfile_map.get(image)
                if dockerfile:
                    await self._build_image_if_missing(
                        image,
                        f"{docker_dir}/{dockerfile}",
                        build_context,
                    )

    async def _build_image_if_missing(
        self, image_name: str, dockerfile_path: str, build_context: str
    ) -> None:
        """Build a Docker image if it doesn't exist."""
        # Check if image exists
        proc = await asyncio.create_subprocess_exec(
            "docker",
            "image",
            "inspect",
            image_name,
            stdout=asyncio.subprocess.DEVNULL,
            stderr=asyncio.subprocess.DEVNULL,
        )
        await proc.wait()

        if proc.returncode != 0:
            logger.info("Building Docker image...", image=image_name)
            proc = await asyncio.create_subprocess_exec(
                "docker",
                "build",
                "-t",
                image_name,
                "-f",
                dockerfile_path,
                build_context,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            _, stderr = await proc.communicate()
            if proc.returncode != 0:
                raise RuntimeError(
                    f"Failed to build image {image_name}: {stderr.decode()}"
                )
            logger.info("Docker image built successfully", image=image_name)

    # =========================================================================
    # PER-AGENT SETTINGS GENERATION
    # =========================================================================

    def _get_role_permissions(
        self, role: str, workspace_path: str, cell_workspace_path: str
    ) -> dict[str, list[str]]:
        """Get role-specific allow/deny lists for Claude Code tools.

        Post-gateway shape: every state-changing operation an agent can
        perform routes through ``mcp__roboco-flow__*`` (intent verbs) or
        ``mcp__roboco-do__*`` (content tools — commit, push, PR, journal,
        notify, message), both granted to every role via ``base_allow``.
        Role-specific configuration here only governs file IO (Write/Edit
        scoping) plus a small handful of legacy native-tool denies that
        remain meaningful for weak models. Read-only git lives in
        ``mcp__roboco-git-readonly__*``.

        Args:
            role: Agent role (developer, qa, documenter, cell_pm, main_pm, etc.)
            workspace_path: Path to agent's own workspace directory
            cell_workspace_path: Path to cell's workspace root (for QA/Docs access)

        Returns:
            Dict with 'allow' and 'deny' lists for Claude Code permissions
        """
        # workspace_path: /data/workspaces/{project}/{team}/{agent}
        # cell_workspace_path: /data/workspaces/{project}/{team}
        configs: dict[str, dict[str, list[str]]] = {
            "developer": {
                "allow": [
                    f"Write({workspace_path}/**)",
                    f"Edit({workspace_path}/**)",
                ],
                "deny": [],
            },
            "qa": {
                # QA reads code + the open PR via the gateway; never edits.
                "allow": [],
                "deny": [
                    "Write(*)",
                    "Edit(*)",
                ],
            },
            "documenter": {
                "allow": [
                    f"Write({cell_workspace_path}/**)",
                    f"Edit({cell_workspace_path}/**)",
                    "Write(/app/docs/**)",
                    "Edit(/app/docs/**)",
                    "Write(/app/CHANGELOG.md)",
                    "Edit(/app/CHANGELOG.md)",
                    "Write(/app/README.md)",
                    "Edit(/app/README.md)",
                ],
                "deny": [],
            },
            "cell_pm": {
                # PMs coordinate; they open + merge PRs through the gateway
                # but never author code. Edit/Write are denied so weaker
                # models can't read the subtask title imperatively and
                # start editing source — they have to decompose into a dev
                # subtask. Devs are the only role that authors code.
                "allow": [],
                "deny": [
                    "Bash(git commit:*)",
                    "Bash(git push:*)",
                    "Write(*)",
                    "Edit(*)",
                ],
            },
            "main_pm": {
                # Same reasoning as cell_pm — Main PM sits between CEO and
                # cell PMs; the work product is coordination + review, not
                # commits or edits. Code work routes Main PM → Cell PM →
                # Dev only.
                "allow": [],
                "deny": [
                    "Bash(git commit:*)",
                    "Bash(git push:*)",
                    "Write(*)",
                    "Edit(*)",
                ],
            },
            "product_owner": {
                "allow": [
                    f"Write({workspace_path}/**)",
                    f"Edit({workspace_path}/**)",
                ],
                "deny": [],
            },
            "head_marketing": {
                "allow": [
                    f"Write({workspace_path}/**)",
                    f"Edit({workspace_path}/**)",
                ],
                "deny": [],
            },
            "auditor": {
                # Auditor is read-only across the org — observes, never edits.
                "allow": [],
                "deny": [
                    "Write(*)",
                    "Edit(*)",
                ],
            },
        }

        if role not in configs:
            logger.warning(
                "No Claude Code permissions configured for role; "
                "agent will be limited to base_allow/base_deny.",
                role=role,
            )
        return configs.get(role, {"allow": [], "deny": []})

    def _generate_agent_settings(
        self,
        agent_id: str,
        role: str,
        workspace_path: str,
        cell_workspace_path: str,
    ) -> Path:
        """Generate per-agent Claude Code settings file with role-specific permissions.

        This replaces the shared settings approach. Each agent gets their own
        settings.json with:
        - Base MCP tools allowed for all agents
        - Role-specific tool permissions
        - Explicit deny list blocking native git/file operations

        Args:
            agent_id: Agent identifier (e.g., "be-dev-1")
            role: Agent role (e.g., "developer")
            workspace_path: Path to agent's own workspace directory
            cell_workspace_path: Path to cell's workspace root (for QA/Docs)

        Returns:
            Path to the generated settings file
        """
        # Base MCP tools for all agents. Post-gateway every role gets the
        # full intent-verb + content-tool surface; the orchestrator-side
        # API rejects verbs/tools the agent's role isn't authorized for,
        # so granting `*` here is safe.
        base_allow = [
            "mcp__roboco-flow__*",
            "mcp__roboco-do__*",
            "mcp__roboco-optimal__*",
            "mcp__roboco-git-readonly__*",
            "Read(*)",  # All agents can read any file
        ]

        # Base denials for all agents - block native tools + sensitive reads.
        # The Read/Bash denies below are critical: without them an agent can
        # read `.git/config` (which, pre-fix, had the PAT embedded in the
        # remote URL) or `~/.gitconfig` and exfiltrate project secrets.
        # We also block direct curl/wget to github.com — any git-remote op
        # must go through the orchestrator's git service, which injects the
        # token via bearer header at subprocess time rather than exposing it.
        base_deny = [
            # Block ALL native git commands - must use roboco_git_* tools
            "Bash(git:*)",
            # Block file ops outside workspace (role-specific allows override)
            "Write(*)",
            "Edit(*)",
            # Block reads of credential stores, anywhere on the FS
            "Read(**/.git/config)",
            "Read(**/.gitconfig)",
            "Read(/etc/gitconfig)",
            "Read(~/.netrc)",
            "Read(**/.git-credentials)",
            # Block direct GitHub API/wire access — agents must use
            # roboco_git_* MCP tools so secrets + traceability stay on the
            # orchestrator side.
            "Bash(curl:*github.com*)",
            "Bash(curl:*api.github.com*)",
            "Bash(wget:*github.com*)",
            "Bash(wget:*api.github.com*)",
            # Same idea for cat-ing credential files in a subshell
            "Bash(cat:*.git/config*)",
            "Bash(cat:*.gitconfig*)",
            "Bash(cat:*.git-credentials*)",
            # Block reading env vars that might leak secrets
            "Bash(env:*)",
            "Bash(printenv:*)",
        ]

        # Get role-specific permissions
        role_config = self._get_role_permissions(
            role, workspace_path, cell_workspace_path
        )

        # Combine base + role-specific.
        # defaultMode=bypassPermissions lets unlisted operations proceed
        # without an interactive prompt (which would hang a non-TTY agent
        # container). Explicit deny rules still apply.
        settings: dict[str, Any] = {
            "permissions": {
                "defaultMode": "bypassPermissions",
                "allow": base_allow + role_config["allow"],
                "deny": base_deny + role_config["deny"],
            },
            "hooks": {
                # Start SDK server on session start (for A2A communication)
                "SessionStart": [
                    {
                        "hooks": [
                            {
                                "type": "command",
                                "command": "/app/scripts/sdk-startup-hook.sh",
                            }
                        ]
                    }
                ],
                # Guard Bash: block shell-level git/curl/wget/env patterns
                # that the matcher-based `permissions.deny` can't catch
                # (e.g. `cd X && git fetch`). Redirects agents to the MCP
                # equivalents instead of bloating prompts with rules.
                "PreToolUse": [
                    {
                        "matcher": "Bash",
                        "hooks": [
                            {
                                "type": "command",
                                "command": "/app/scripts/bash-guard-hook.sh",
                            }
                        ],
                    },
                ],
                "PostToolUse": [
                    # Traceability reminders (context-aware, runs on specific tools)
                    {
                        "matcher": "|".join(TRACEABILITY_TRIGGER_TOOLS),
                        "hooks": [
                            {
                                "type": "command",
                                "command": "/app/scripts/traceability-hook.sh",
                            }
                        ],
                    },
                    # Check for incoming A2A messages after each tool use
                    {
                        "matcher": "*",
                        "hooks": [
                            {
                                "type": "command",
                                "command": "/app/scripts/a2a-check-hook.sh",
                            }
                        ],
                    },
                    # Per-session budget counter + loop detector. Shared SDK
                    # state lets this hook emit [Budget]/[Loop]/[Halt]
                    # reminders that the orchestrator's kill-switch corroborates.
                    {
                        "matcher": "*",
                        "hooks": [
                            {
                                "type": "command",
                                "command": "/app/scripts/post-tool-budget-hook.sh",
                            }
                        ],
                    },
                ],
                # Stop guard: refuse silent exits unless a terminal tool was
                # just called (idle/substitute/escalate/pause/...). Second
                # attempt auto-substitutes via SDK so the task doesn't rot.
                "Stop": [
                    {
                        "hooks": [
                            {
                                "type": "command",
                                "command": "/app/scripts/stop-hook.sh",
                            }
                        ]
                    }
                ],
                # Prompt-injection guard — rejects turns that look like
                # another agent's content trying to override our rules.
                "UserPromptSubmit": [
                    {
                        "hooks": [
                            {
                                "type": "command",
                                "command": "/app/scripts/user-prompt-hook.sh",
                            }
                        ]
                    }
                ],
                # Snapshot budget / terminal state before compact so the
                # next session resumes with continuity.
                "PreCompact": [
                    {
                        "hooks": [
                            {
                                "type": "command",
                                "command": "/app/scripts/pre-compact-hook.sh",
                            }
                        ]
                    }
                ],
                # Post-mortem: write a reflect-journal entry summarising the
                # session (tools called, halt/loop triggered, last tool).
                "SessionEnd": [
                    {
                        "hooks": [
                            {
                                "type": "command",
                                "command": "/app/scripts/session-end-hook.sh",
                            }
                        ]
                    }
                ],
            },
        }

        # Write to per-agent settings file
        # When running in container: write to /app/agent-settings (mounted to host)
        # When running on host: use temp directory
        if DATA_HOST_PATH:
            settings_dir = Path("/app/agent-settings")
        else:
            settings_dir = Path(tempfile.gettempdir()) / "roboco-agent-settings"

        settings_dir.mkdir(parents=True, exist_ok=True)
        settings_path = settings_dir / f"{agent_id}-settings.json"

        # Handle case where Docker auto-created a directory instead of a file
        if settings_path.is_dir():
            shutil.rmtree(settings_path)

        settings_path.write_text(json.dumps(settings, indent=2))

        logger.debug(
            "Generated per-agent settings",
            agent_id=agent_id,
            role=role,
            settings_path=str(settings_path),
            allow_count=len(settings["permissions"]["allow"]),
            deny_count=len(settings["permissions"]["deny"]),
        )

        return settings_path

    # =========================================================================
    # AGENT SPAWNING
    # =========================================================================

    def _task_git_context(self, task: dict[str, Any]) -> SpawnGitContext | None:
        """Build SpawnGitContext from a task dict for workspace mounting.

        Without this, spawned agents fall back to project_slug="default"
        and get a Write/Edit permission lock to /data/workspaces/default/...
        which does not exist, so the agent's file tools fail.
        """
        project_slug = task.get("project_slug")
        if not project_slug:
            return None
        return SpawnGitContext(
            project_slug=project_slug,
            branch_name=task.get("branch_name"),
        )

    def _fire_audit(
        self,
        *,
        event_type: str,
        agent_slug: str,
        task_id: str | None = None,
        details: dict[str, Any] | None = None,
        severity: str = "info",
    ) -> None:
        """Emit an agent-lifecycle audit event without blocking the caller.

        Strong-refs the Task so it isn't garbage-collected before it
        commits to `audit_log`. Silently skips if there's no running loop
        (e.g. sync unit tests).
        """
        import contextlib as _ctx

        from roboco.services.audit import get_audit_service

        with _ctx.suppress(RuntimeError):
            bg = asyncio.get_running_loop().create_task(
                get_audit_service().log_agent_event(
                    event_type=event_type,
                    agent_slug=agent_slug,
                    task_id=task_id,
                    details=details or {},
                    severity=severity,
                )
            )
            self._bg_tasks.add(bg)
            bg.add_done_callback(self._bg_tasks.discard)

    async def _git_context_default_project(self) -> SpawnGitContext | None:
        """Return git context for the 'default' project when no task is known.

        Used by no-task spawns (idle PM, scanner-only agents). Picks the
        first active project in the DB — the common case is a single-project
        deployment, where this resolves to the correct slug; for multi-
        project deployments the caller should pass task_id to disambiguate.
        """
        from sqlalchemy import select

        from roboco.db.base import get_db_context
        from roboco.db.tables import ProjectTable

        try:
            async with get_db_context() as db:
                result = await db.execute(
                    select(ProjectTable.slug, ProjectTable.default_branch)
                    .where(ProjectTable.is_active.is_(True))
                    .order_by(ProjectTable.created_at.asc())
                    .limit(1)
                )
                row = result.first()
                if row is None:
                    return None
                slug, default_branch = row
                if not slug:
                    return None
                return SpawnGitContext(
                    project_slug=slug,
                    branch_name=default_branch,
                )
        except Exception as e:
            logger.warning(
                "Could not derive default project git context",
                error=str(e),
            )
            return None

    async def _git_context_from_task_id(self, task_id: str) -> SpawnGitContext | None:
        """Load a task by ID and derive git context for spawning.

        Used by `spawn_agent` when called without an explicit git_context
        (e.g. the /agents/{slug}/spawn API endpoint). Without this, agents
        spawned via that endpoint get project_slug="default" and their
        workspace mount points at a path that doesn't exist.
        """
        from sqlalchemy import select

        from roboco.db.base import get_db_context
        from roboco.db.tables import ProjectTable, TaskTable

        try:
            async with get_db_context() as db:
                result = await db.execute(
                    select(TaskTable.branch_name, ProjectTable.slug)
                    .select_from(TaskTable)
                    .join(ProjectTable, TaskTable.project_id == ProjectTable.id)
                    .where(TaskTable.id == task_id)
                )
                row = result.first()
                if row is None:
                    return None
                branch_name, project_slug = row
                if not project_slug:
                    return None
                return SpawnGitContext(
                    project_slug=project_slug,
                    branch_name=branch_name,
                )
        except Exception as e:
            logger.warning(
                "Could not derive git context from task_id",
                task_id=task_id,
                error=str(e),
            )
            return None

    async def _safe_spawn(
        self,
        *,
        agent_id: str,
        task_id: str | None = None,
        initial_prompt: str | None = None,
        git_context: SpawnGitContext | None = None,
        context_label: str = "dispatcher",
    ) -> AgentInstance | None:
        """Spawn an agent, absorbing errors so one bad spawn doesn't abort the
        rest of the dispatcher's loop.

        Each dispatcher iterates many tasks; if `spawn_agent` raised, the
        remaining tasks were skipped until the next tick. This wrapper logs
        and returns None on failure so siblings still get dispatched.

        When ``settings.gateway_enabled`` is True the gateway pre-spawn check
        runs first; a QUEUE or DROP outcome skips the container launch.
        When the flag is False (Phase 0 default) this block is a single
        boolean test and the legacy path is completely unchanged.
        """
        # Gateway gate — zero-cost when gateway_enabled=False (Phase 0 default).
        target_role = get_agent_role(agent_id) or "unknown"
        # Map context_label to one of the TriggerKind string values; unknown
        # labels fall back to "scan" which is the least-specific kind.
        trigger_kind_map = {
            "a2a": "a2a",
            "escalation": "escalation",
            "notification": "notification",
        }
        trigger_kind = trigger_kind_map.get(context_label, "scan")

        outcome, reason = await gateway_pre_spawn_check(
            task_id=task_id,
            trigger_kind=trigger_kind,
            target_role=target_role,
        )
        if outcome != "spawn":
            logger.info(
                "Gateway pre-spawn check suppressed spawn",
                agent_id=agent_id,
                task_id=task_id,
                outcome=outcome,
                reason=reason,
            )
            return None

        try:
            return await self.spawn_agent(
                agent_id=agent_id,
                task_id=task_id,
                initial_prompt=initial_prompt,
                git_context=git_context,
            )
        except Exception as e:
            logger.error(
                "Spawn failed during dispatch; continuing with next task",
                context=context_label,
                agent_id=agent_id,
                task_id=task_id,
                error=str(e),
            )
            return None

    async def _resolve_spawn_git_context(
        self,
        git_context: SpawnGitContext | None,
        task_id: str | None,
    ) -> SpawnGitContext | None:
        """Auto-derive git context if the caller didn't supply one."""
        if git_context is not None and git_context.project_slug:
            return git_context
        derived: SpawnGitContext | None = None
        if task_id:
            derived = await self._git_context_from_task_id(task_id)
        if derived is None:
            derived = await self._git_context_default_project()
        return derived if derived is not None else git_context

    def _existing_running_instance(self, agent_id: str) -> AgentInstance | None:
        """Return the running instance for agent_id, or None if it can be respawned."""
        existing = self._instances.get(agent_id)
        if existing is None:
            return None
        if existing.state in (AgentState.OFFLINE, AgentState.WAITING_LONG):
            return None
        logger.warning(
            "Agent already running",
            agent_id=agent_id,
            state=existing.state,
        )
        return existing

    def _resolve_project_slug(
        self,
        git_context: SpawnGitContext | None,
        agent_id: str,
        task_id: str | None,
    ) -> str:
        """Pull project_slug from context, or fall back to 'default' with a warning."""
        project_slug = (
            git_context.project_slug
            if git_context and git_context.project_slug
            else None
        )
        if not project_slug:
            logger.warning(
                "Spawning agent without project_slug; workspace fallback used. "
                "Agent file tools will be locked to a nonexistent path.",
                agent_id=agent_id,
                task_id=task_id,
            )
            project_slug = "default"
        return project_slug

    async def _prepare_agent_spawn(
        self,
        agent_id: str,
        task_id: str | None,
        model: str | None,
        git_context: SpawnGitContext | None,
    ) -> tuple[AgentConfig, AgentInstance, Path | None]:
        """Build AgentConfig + AgentInstance and surface per-agent settings path."""
        blueprint_path = self._generate_composed_prompt(agent_id)
        canonical_role = get_agent_role(agent_id)
        team = get_agent_team(agent_id) or "backend"

        # Resolve the provider route for this agent. Caller-supplied `model`
        # wins (dispatcher overrides, tests). Otherwise the routing service
        # resolves (agent_slug | role | global) assignments, falling back
        # internally to `ROLE_MODEL_MAP` when no rows exist — so a fresh
        # deployment with an empty `model_assignments` table behaves exactly
        # as before.
        route = await self._resolve_agent_route(agent_id)
        if not model:
            model = route.model_name

        project_slug = self._resolve_project_slug(git_context, agent_id, task_id)
        workspace_path = _agent_workspace_path(project_slug, team, agent_id)
        cell_workspace_path = _cell_workspace_path(project_slug, team)

        agent_settings_path = self._generate_agent_settings(
            agent_id, canonical_role, workspace_path, cell_workspace_path
        )

        briefing_path = await self._write_agent_briefing(
            agent_id, task_id, workspace_path
        )

        await self._ensure_agent_image(agent_id)
        mcp_config_path = await self._generate_mcp_config(agent_id, git_context)

        config = AgentConfig(
            agent_id=agent_id,
            blueprint_path=blueprint_path,
            model=model,
            mcp_config_path=mcp_config_path,
            git_context=git_context,
            briefing_path=briefing_path,
            provider_type=route.provider_type.value,
            provider_base_url=route.base_url,
            provider_auth_token=route.auth_token,
        )
        instance = AgentInstance(
            agent_id=agent_id,
            state=AgentState.STARTING,
            config=config,
            current_task_id=task_id,
        )
        self._instances[agent_id] = instance
        return config, instance, agent_settings_path

    async def _launch_spawn(
        self,
        task_id: str | None,
        config: AgentConfig,
        instance: AgentInstance,
        initial_prompt: str | None,
        agent_settings_path: Path | None,
    ) -> AgentInstance:
        """Launch the container and emit spawn audit events.

        `agent_id` was dropped as a redundant parameter — `config.agent_id`
        is the same value and was always the caller's source.
        """
        agent_slug = config.agent_id
        try:
            container_id = await self._spawn_container(
                config, initial_prompt, agent_settings_path
            )
            instance.container_id = container_id
            instance.state = AgentState.ACTIVE
            instance.started_at = datetime.now(UTC)
            instance.last_activity = datetime.now(UTC)

            logger.info(
                "Agent spawned",
                agent_id=agent_slug,
                container_id=container_id[:12],
                model=config.model,
                task_id=task_id,
            )

            self._fire_audit(
                event_type="agent.spawned",
                agent_slug=agent_slug,
                task_id=task_id,
                details={
                    "container_id": container_id[:12],
                    "model": config.model,
                },
            )
            return instance
        except Exception as e:
            instance.state = AgentState.OFFLINE
            instance.error_count += 1
            logger.error(
                "Failed to spawn agent",
                agent_id=agent_slug,
                error=str(e),
            )
            self._fire_audit(
                event_type="agent.spawn_failed",
                agent_slug=agent_slug,
                task_id=task_id,
                details={"error": str(e)},
                severity="error",
            )
            raise

    async def spawn_agent(
        self,
        agent_id: str,
        initial_prompt: str | None = None,
        task_id: str | None = None,
        model: str | None = None,
        git_context: SpawnGitContext | None = None,
    ) -> AgentInstance:
        """
        Spawn a Claude Code container for an agent.

        Args:
            agent_id: Agent identifier (e.g., "be-dev-1")
            initial_prompt: Optional initial prompt
            task_id: Optional task ID being worked on
            model: Override model selection
            git_context: Optional git context (project_slug, branch_name)

        Returns:
            AgentInstance handle

        Raises:
            AgentReadinessError: task is not spawn-ready (missing criteria,
                missing git token, no branch plan, role mismatch). The task
                is auto-blocked before we raise so the dispatcher doesn't
                keep retrying.
        """
        # Pre-flight: refuse to spawn if the task isn't ready. Auto-block
        # on refusal so the dispatcher doesn't keep spinning a container
        # that will immediately fail (wasted image pull + startup tokens).
        readiness_reason = await self._readiness_gate(agent_id, task_id)
        if readiness_reason:
            raise AgentReadinessError(
                f"spawn refused for {agent_id} (task={task_id}): {readiness_reason}"
            )

        # Auto-derive git_context when the caller didn't supply one. Two
        # paths:
        #   (a) task_id present  → look up the task's project;
        #   (b) no task_id       → fall back to the sole active project (or
        #                          the first one if there are multiple).
        # Without (b), no-task spawns (e.g. idle PM bootstrapping) hit the
        # "workspace fallback used" path and get mounted at
        # /data/workspaces/default/... which doesn't exist.
        git_context = await self._resolve_spawn_git_context(git_context, task_id)

        async with self._lock:
            existing = self._existing_running_instance(agent_id)
            if existing is not None:
                return existing
            config, instance, agent_settings_path = await self._prepare_agent_spawn(
                agent_id, task_id, model, git_context
            )
        # Record the task as handled so later dispatchers in the same
        # tick don't act on it again. Safe even if _launch_spawn fails
        # — the next tick starts fresh.
        self._mark_task_handled(task_id)
        return await self._launch_spawn(
            task_id,
            config,
            instance,
            initial_prompt,
            agent_settings_path,
        )

    def _resolve_host_paths(
        self, config: AgentConfig, agent_settings_path: Path | None
    ) -> dict[str, str | None]:
        """Compute host mount paths for both containerized and host runtime."""
        mcp_name = config.mcp_config_path.name if config.mcp_config_path else ""
        if PROJECT_HOST_PATH:
            return {
                "blueprints": f"{PROJECT_HOST_PATH}/agents/blueprints",
                "docs": f"{PROJECT_HOST_PATH}/docs",
                "workspaces": f"{DATA_HOST_PATH}/workspaces",
                "claude": CLAUDE_AUTH_HOST_PATH,
                "mcp_config": f"{DATA_HOST_PATH}/mcp-configs/{mcp_name}",
                "prompt": (
                    f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md"
                ),
                "settings": (
                    f"{DATA_HOST_PATH}/agent-settings/{config.agent_id}-settings.json"
                    if agent_settings_path
                    else None
                ),
                "briefing": (
                    f"{DATA_HOST_PATH}/briefings/{config.agent_id}.md"
                    if config.briefing_path
                    else None
                ),
            }
        return {
            "blueprints": str(self.blueprints_dir.absolute()),
            "docs": str(self.blueprints_dir.parent / "docs"),
            "workspaces": str(Path(settings.workspaces_root)),
            "claude": CLAUDE_AUTH_HOST_PATH,
            "mcp_config": str(config.mcp_config_path),
            "prompt": str(
                Path(tempfile.gettempdir())
                / "roboco-prompts"
                / f"{config.agent_id}-prompt.md"
            ),
            "settings": str(agent_settings_path) if agent_settings_path else None,
            "briefing": (str(config.briefing_path) if config.briefing_path else None),
        }

    @staticmethod
    def _build_mount_args(
        container_name: str, config: AgentConfig, hosts: dict[str, str | None]
    ) -> list[str]:
        """Compose `docker run -v/-e` mount + env args for the agent."""
        cmd: list[str] = [
            "docker",
            "run",
            "-d",
            "--name",
            container_name,
            "--network",
            AGENT_NETWORK,
            # Mount Claude auth directory (for API keys, etc.)
            "-v",
            f"{hosts['claude']}:/home/agent/.claude",
        ]
        AgentOrchestrator._append_claude_json_mount(cmd, hosts)
        AgentOrchestrator._append_optional_host_mounts(cmd, hosts)
        role = get_agent_role(config.agent_id) or "developer"
        cmd.extend(AgentOrchestrator._core_volume_and_env_args(config, hosts, role))
        AgentOrchestrator._append_provider_env(cmd, config)
        subagent_model = _resolve_agent_cli_model(config.provider_type, config.model)
        cmd.extend(["-e", f"CLAUDE_CODE_SUBAGENT_MODEL={subagent_model}"])
        AgentOrchestrator._append_manifest_args(cmd, config, subagent_model)
        AgentOrchestrator._append_workspace_cwd(cmd, config)
        return cmd

    @staticmethod
    def _append_claude_json_mount(cmd: list[str], hosts: dict[str, str | None]) -> None:
        """Mount host's ~/.claude.json sibling FILE if present (audit D-48)."""
        claude_dir = hosts["claude"]
        if not claude_dir:
            return
        claude_json_host = f"{claude_dir.rstrip('/')}.json"
        if Path(claude_json_host).exists():
            cmd.extend(["-v", f"{claude_json_host}:/home/agent/.claude.json"])

    @staticmethod
    def _append_optional_host_mounts(
        cmd: list[str], hosts: dict[str, str | None]
    ) -> None:
        """Mount agent settings.json and briefing.md when their hosts exist."""
        settings_host = hosts.get("settings")
        if settings_host:
            cmd.extend(["-v", f"{settings_host}:/home/agent/.claude/settings.json:ro"])
        briefing_host = hosts.get("briefing")
        if briefing_host:
            cmd.extend(["-v", f"{briefing_host}:/app/briefing.md:ro"])

    @staticmethod
    def _core_volume_and_env_args(
        config: AgentConfig, hosts: dict[str, str | None], role: str
    ) -> list[str]:
        """The always-on -v/-e block (prompt, blueprints, docs, workspaces, env)."""
        docs_ro = "" if config.agent_id in ALL_DOCS else ":ro"
        return [
            "-v",
            f"{hosts['prompt']}:/app/system-prompt.md:ro",
            "-v",
            f"{hosts['blueprints']}:/app/agents/blueprints:ro",
            "-v",
            f"{hosts['docs']}:/app/docs{docs_ro}",
            "-v",
            f"{hosts['workspaces']}:/data/workspaces",
            "-v",
            f"{hosts['mcp_config']}:/app/mcp-config.json:ro",
            "-e",
            f"ROBOCO_AGENT_ID={config.agent_id}",
            "-e",
            f"ROBOCO_AGENT_ROLE={role}",
            "-e",
            "ROBOCO_API_URL=http://roboco-orchestrator:8000",
            "-e",
            "ROBOCO_SDK_PORT=9000",
            "-e",
            "ROBOCO_SDK_URL=http://localhost:9000",
            "-e",
            f"ROBOCO_AGENT_TOOL_CALL_WARN={settings.agent_tool_call_warn}",
            "-e",
            f"ROBOCO_AGENT_TOOL_CALL_HALT={settings.agent_tool_call_halt}",
            "-e",
            f"ROBOCO_AGENT_LOOP_THRESHOLD={settings.agent_loop_threshold}",
            "-e",
            f"ROBOCO_AGENT_LOOP_WINDOW={settings.agent_loop_window}",
            "-e",
            f"ROBOCO_AGENT_STOP_ATTEMPT_ALLOWANCE={settings.agent_stop_attempt_allowance}",
        ]

    @staticmethod
    def _append_provider_env(cmd: list[str], config: AgentConfig) -> None:
        """Inject ANTHROPIC_* env only on non-Anthropic providers."""
        # Provider routing: only inject ANTHROPIC_* env vars when the
        # resolved provider is non-Anthropic (i.e. Ollama Cloud). For the
        # Anthropic default path both fields are None and Claude Code
        # inside the container continues to use its mounted ~/.claude
        # credentials — preserving legacy behaviour byte-for-byte.
        if config.provider_base_url:
            cmd.extend(["-e", f"ANTHROPIC_BASE_URL={config.provider_base_url}"])
        if config.provider_auth_token:
            cmd.extend(["-e", f"ANTHROPIC_AUTH_TOKEN={config.provider_auth_token}"])

    @staticmethod
    def _append_manifest_args(
        cmd: list[str], config: AgentConfig, subagent_model: str
    ) -> None:
        """Write the spawn manifest and flip the gateway flag."""
        # Spawn manifest + gateway flag — developer role only in Phase 1.
        # _build_manifest_for_agent writes the JSON file to the host and
        # returns the path; other roles get None and the gateway flag stays off.
        manifest_host_path = _build_manifest_for_agent(config.agent_id, subagent_model)
        if manifest_host_path:
            cmd.extend(
                [
                    "-v",
                    f"{manifest_host_path}:/app/tool-manifest.json:ro",
                    "-e",
                    "ROBOCO_GATEWAY_ENABLED=true",
                    "-e",
                    "ROBOCO_TOOL_MANIFEST_PATH=/app/tool-manifest.json",
                ]
            )
        else:
            cmd.extend(["-e", "ROBOCO_GATEWAY_ENABLED=false"])

    _ROLES_WITH_AGENT_WORKSPACE: ClassVar[frozenset[str]] = frozenset(
        {"developer", "product_owner", "head_marketing"}
    )
    _ROLES_WITH_CELL_WORKSPACE: ClassVar[frozenset[str]] = frozenset({"documenter"})

    @staticmethod
    def _append_workspace_cwd(cmd: list[str], config: AgentConfig) -> None:
        """Set the container -w to the agent or cell workspace by role."""
        # Pre-gateway parity (Wave A2+A3, 2026-05-12). Set the container's cwd
        # to the agent's task workspace so Edit/Write resolve to paths that
        # match _get_role_permissions allowlist, and `git add` operates inside
        # the workspace clone. Without this, container WORKDIR (/app from the
        # Dockerfile) shadows the workspace and every file op fails.
        #
        # Mirror the workspace-path selection in _get_role_permissions exactly:
        # - developer / product_owner / head_marketing: per-agent workspace
        # - documenter: cell workspace
        # - qa / cell_pm / main_pm / auditor: no write workspace → omit -w
        role = get_agent_role(config.agent_id) or "developer"
        team = get_agent_team(config.agent_id) or ""
        project = _resolve_project_slug_from_git_context(config.git_context)
        if role in AgentOrchestrator._ROLES_WITH_AGENT_WORKSPACE:
            cmd.extend(["-w", _agent_workspace_path(project, team, config.agent_id)])
        elif role in AgentOrchestrator._ROLES_WITH_CELL_WORKSPACE:
            cmd.extend(["-w", _cell_workspace_path(project, team)])

    @staticmethod
    def _append_agent_auth_env(cmd: list[str], config: AgentConfig) -> None:
        """Append agent HMAC token env var to the docker run cmd."""
        # Agent HMAC auth token — bound to (agent_id, role, team). The
        # API middleware refuses requests whose headers don't match the
        # token, which stops one agent on the Docker network from
        # spoofing another agent's role. Token is stable per agent as
        # long as the secret doesn't rotate, so it's fine to compute at
        # spawn time and inject once.
        from roboco.agents_config import (
            get_agent_role as _get_role,
        )
        from roboco.agents_config import (
            get_agent_team as _get_team,
        )
        from roboco.agents_config import (
            issue_agent_token,
        )

        _role = _get_role(config.agent_id)
        _team = _get_team(config.agent_id) or ""
        _token = issue_agent_token(config.agent_id, _role, _team)
        cmd.extend(["-e", f"ROBOCO_AGENT_TOKEN={_token}"])

    @staticmethod
    def _append_git_context_env(cmd: list[str], config: AgentConfig) -> None:
        """Append git-context env vars to the docker run cmd."""
        if not config.git_context:
            return
        if config.git_context.project_slug:
            cmd.extend(["-e", f"ROBOCO_PROJECT_SLUG={config.git_context.project_slug}"])
        if config.git_context.branch_name:
            cmd.extend(["-e", f"ROBOCO_BRANCH={config.git_context.branch_name}"])

    @staticmethod
    def _default_spawn_prompt() -> str:
        """Fallback prompt when the caller provided none."""
        return (
            "You may have been spawned without a specific task assignment. "
            "Follow your standard workflow:\n\n"
            "1. Call `roboco_task_scan()` to find work for your role\n"
            "2. If tasks found, claim with `roboco_task_claim(task_id)` "
            "and begin: UNDERSTAND -> PLAN -> EXECUTE -> VERIFY -> HANDOFF\n"
            "3. If no tasks available, call `roboco_agent_idle()` "
            "to shutdown gracefully\n\n"
            "Start now by scanning for work."
        )

    @classmethod
    def _append_image_and_claude_args(
        cls, cmd: list[str], config: AgentConfig, initial_prompt: str | None
    ) -> None:
        """Append the image + Claude Code CLI args to the docker run cmd.

        `--tools` explicitly enumerates the built-in tools loaded at session
        start. Without it, Claude CLI's default behavior leaves Edit/Write
        in the deferred pool, so an agent that doesn't reliably call
        ToolSearch (e.g. weaker non-Anthropic models routed via
        Ollama-cloud) ends up unable to modify any file. The set below is
        the minimum every agent role needs:
          - Read/Write/Edit  : file IO inside the workspace
          - Bash             : shell commands (gated by bash-guard hook)
          - Grep/Glob        : code navigation
          - Task             : sub-agent dispatch (used for ToolSearch and
                               other delegated jobs)
          - TodoWrite        : per-session planning
        Permissions still gate *which* paths Edit/Write can touch (see
        `_get_role_permissions`), so this is purely about loading vs
        denying.
        """
        cmd.extend(
            [
                get_agent_image(config.agent_id),
                "--model",
                cls._resolve_cli_model(config),
                "--system-prompt-file",
                "/app/system-prompt.md",
                "--mcp-config",
                "/app/mcp-config.json",
                "--strict-mcp-config",
                "--tools",
                "Read,Write,Edit,Bash,Grep,Glob,Task,TodoWrite",
                "--output-format",
                "stream-json",
                "--verbose",
                "-p",
                initial_prompt or cls._default_spawn_prompt(),
            ]
        )

    @staticmethod
    def _resolve_cli_model(config: AgentConfig) -> str:
        """Return the string to pass to `claude --model`."""
        return _resolve_agent_cli_model(config.provider_type, config.model)

    async def _spawn_container(
        self,
        config: AgentConfig,
        initial_prompt: str | None = None,
        agent_settings_path: Path | None = None,
    ) -> str:
        """Spawn a Docker container for the agent.

        Args:
            config: Agent configuration
            initial_prompt: Optional initial prompt for the agent
            agent_settings_path: Path to per-agent Claude settings file
        """
        container_name = f"roboco-agent-{config.agent_id}"
        await self._remove_container(container_name)

        if not config.mcp_config_path:
            raise RuntimeError("MCP config path not set")

        hosts = self._resolve_host_paths(config, agent_settings_path)
        cmd = self._build_mount_args(container_name, config, hosts)
        self._append_agent_auth_env(cmd, config)
        self._append_git_context_env(cmd, config)
        self._append_image_and_claude_args(cmd, config, initial_prompt)

        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()

        if proc.returncode != 0:
            raise RuntimeError(f"Failed to start container: {stderr.decode()}")

        return stdout.decode().strip()

    async def _remove_container(self, container_name: str) -> None:
        """Remove a container if it exists, dumping its logs to disk first.

        Docker deletes the container's json-file log when we `docker rm`, so
        before removal we copy the current log to /data/logs/agents/{slug}/
        with a timestamp. That gives us persistent history across respawns
        without needing an entrypoint wrapper inside the agent image.
        """
        # Check the container actually exists before trying to dump logs;
        # _remove_container is routinely called pre-spawn to clear stale
        # containers, and on first spawn there's nothing to dump.
        inspect = await asyncio.create_subprocess_exec(
            "docker",
            "inspect",
            "--format={{.Id}}",
            container_name,
            stdout=asyncio.subprocess.DEVNULL,
            stderr=asyncio.subprocess.DEVNULL,
        )
        exists = (await inspect.wait()) == 0

        if exists:
            slug = container_name.removeprefix("roboco-agent-")
            log_dir = Path("/data/logs/agents") / slug
            try:
                log_dir.mkdir(parents=True, exist_ok=True)
                timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
                log_path = log_dir / f"{timestamp}.log"
                with log_path.open("wb") as out:
                    dump_proc = await asyncio.create_subprocess_exec(
                        "docker",
                        "logs",
                        container_name,
                        stdout=out,
                        stderr=out,
                    )
                    await dump_proc.wait()
                if log_path.stat().st_size == 0:
                    log_path.unlink(missing_ok=True)
            except Exception as e:
                logger.warning(
                    "Could not dump container logs before removal",
                    container=container_name,
                    error=str(e),
                )

        proc = await asyncio.create_subprocess_exec(
            "docker",
            "rm",
            "-f",
            container_name,
            stdout=asyncio.subprocess.DEVNULL,
            stderr=asyncio.subprocess.DEVNULL,
        )
        await proc.wait()

    async def _generate_mcp_config(
        self,
        agent_id: str,
        git_context: SpawnGitContext | None = None,
    ) -> Path:
        """Generate MCP config for an agent.

        Post-gateway: every state-changing tool routes through one of two
        servers, and read-only views go through two more:

        - roboco-flow         intent verbs (lifecycle transitions)
        - roboco-do           content tools (commit, push, PR, journal,
                              notify, message)
        - roboco-git-readonly status, log, diff, branch list
        - roboco-optimal      knowledge base, RAG, semantic search
        - roboco-docs         documentation file management (panel docs)

        The agent's role is asserted by the orchestrator API on every
        verb/tool call, so all roles get the same MCP surface from this
        registration; verbs the agent's role can't run return a
        not-authorized error rather than 404. Git context is forwarded
        only as a fallback for tools that resolve project/branch from env.
        """
        # MCP servers run inside agent containers, need to connect via Docker network
        if PROJECT_HOST_PATH:
            api_url = "http://roboco-orchestrator:8000"
        else:
            api_url = f"http://127.0.0.1:{settings.port}"

        agent_role = get_agent_role(agent_id) or ""
        # Gateway v2 endpoints declare X-Agent-ID as Annotated[UUID, Header(...)],
        # so the MCP server has to forward the agent's UUID — not the slug — or
        # every gateway call 422s on header parse. Resolve via AGENT_UUIDS map;
        # if the slug isn't in the map (custom agents), fall back to the slug
        # and let the API surface the unknown-agent error.
        agent_uuid = AGENT_UUIDS.get(agent_id, agent_id)

        mcp_env: dict[str, str] = {
            "ROBOCO_API_URL": api_url,
            "ROBOCO_ORCHESTRATOR_URL": api_url,
            "ROBOCO_AGENT_ID": agent_uuid,
            "ROBOCO_AGENT_ROLE": agent_role,
        }

        # Add git context if available
        if git_context:
            if git_context.project_slug:
                mcp_env["ROBOCO_PROJECT_SLUG"] = git_context.project_slug
            if git_context.branch_name:
                mcp_env["ROBOCO_BRANCH"] = git_context.branch_name

        mcp_servers: dict[str, dict[str, Any]] = {
            # Intent verbs — every role-scoped lifecycle transition.
            "roboco-flow": {
                "command": "uv",
                "args": ["run", "python", "-m", "roboco.mcp.flow_server"],
                "env": mcp_env,
            },
            # Content tools — commit, push, PR, journal, notify, message.
            "roboco-do": {
                "command": "uv",
                "args": ["run", "python", "-m", "roboco.mcp.do_server"],
                "env": mcp_env,
            },
            # Read-only git views — status, log, diff, branches.
            "roboco-git-readonly": {
                "command": "uv",
                "args": ["run", "python", "-m", "roboco.mcp.git_readonly"],
                "env": mcp_env,
            },
            # Knowledge base — RAG / semantic search / ask_mentor.
            "roboco-optimal": {
                "command": "uv",
                "args": [
                    "run",
                    "python",
                    "-m",
                    "roboco.mcp.optimal_server",
                    agent_id,
                ],
                "env": mcp_env,
            },
        }

        # Docs server — documentation file management. Registered only for
        # roles that touch panel docs; handlers still enforce per-role
        # access so the surface is fail-closed.
        docs_roles = (
            "documenter",
            "cell_pm",
            "main_pm",
            "product_owner",
            "head_marketing",
        )
        if agent_role in docs_roles:
            mcp_servers["roboco-docs"] = {
                "command": "uv",
                "args": [
                    "run",
                    "python",
                    "-m",
                    "roboco.mcp.docs_server",
                    agent_id,
                ],
                "env": mcp_env,
            }

        config: dict[str, Any] = {"mcpServers": mcp_servers}

        # Write to shared config directory (mounted in both orchestrator and agents)
        # When running in container: /app/mcp-configs -> host's ./data/mcp-configs
        # When running on host: use temp directory
        if DATA_HOST_PATH:
            # Running in container - use shared mounted directory
            config_dir = Path("/app/mcp-configs")
            config_dir.mkdir(parents=True, exist_ok=True)
        else:
            # Running on host - use temp directory
            config_dir = Path(tempfile.gettempdir())

        config_path = config_dir / f"roboco-mcp-{agent_id}.json"
        config_path.write_text(json.dumps(config, indent=2))

        return config_path

    def _generate_composed_prompt(self, agent_id: str) -> Path:
        """Generate composed system prompt for an agent.

        Uses the layered prompt composition system:
        base.md + roles/{role}.md + teams/{team}.md + identities/{agent}.md

        Returns:
            Path to the generated prompt file
        """
        # Get role and team from canonical config
        role_str = get_agent_role(agent_id)
        team_str = get_agent_team(agent_id)

        # Convert to enums
        role_enum = AgentRole(role_str) if role_str else None
        team_enum = Team(team_str) if team_str else None

        if not role_enum:
            raise ValueError(f"Unknown role for agent: {agent_id}")

        # Compose the prompt from layers
        prompt_content = compose_prompt(role_enum, team_enum, agent_id)

        # Determine output directory
        if PROJECT_HOST_PATH:
            # Running in container - use shared directory that maps to host
            config_dir = Path("/app/prompts-generated")
            config_dir.mkdir(parents=True, exist_ok=True)
        else:
            # Running directly on host
            config_dir = Path(tempfile.gettempdir()) / "roboco-prompts"
            config_dir.mkdir(parents=True, exist_ok=True)

        # Write to file
        prompt_path = config_dir / f"{agent_id}-prompt.md"
        prompt_path.write_text(prompt_content)

        logger.debug(
            "Generated composed prompt",
            agent_id=agent_id,
            role=role_str,
            team=team_str,
            path=str(prompt_path),
            size=len(prompt_content),
        )

        return prompt_path

    async def _readiness_gate(self, agent_id: str, task_id: str | None) -> str | None:
        """Return a reason string if the spawn must be refused, else None.

        Checks run only when a task is being spawned for. No-task spawns
        (idle PM bootstrap, etc.) are always ready. On any refusal that
        represents a persistent problem we auto-block the task so the
        dispatcher stops retrying — the PM sees the block notification.
        """
        if not task_id:
            return None

        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                task_or_reason = await self._readiness_fetch_task(client, task_id)
                if isinstance(task_or_reason, str):
                    return task_or_reason
                task = task_or_reason

                persistent = self._readiness_check_task(agent_id, task)
                if persistent is not None:
                    return await self._readiness_block(client, task_id, persistent)

                project_slug = _read_project_slug(task)
                token_reason = await self._readiness_check_git_token(project_slug)
                if token_reason is not None:
                    return await self._readiness_block(client, task_id, token_reason)
        except httpx.HTTPError as e:
            # Transient — retry on next dispatch without auto-blocking.
            return f"readiness check HTTP error: {e}"

        return None

    async def _readiness_fetch_task(
        self, client: httpx.AsyncClient, task_id: str
    ) -> dict[str, Any] | str:
        """Fetch the task or return a reason string.

        404 → "task not found" (caller should auto-block).
        Other non-200s → transient; caller returns the reason verbatim
        without auto-blocking so the dispatcher can retry next tick.
        """
        resp = await client.get(f"{self._api_url}/tasks/{task_id}")
        if resp.status_code == http_status.HTTP_404_NOT_FOUND:
            await self._readiness_block(client, task_id, "task not found")
            return "task not found"
        if resp.status_code != http_status.HTTP_200_OK:
            return f"task-fetch returned {resp.status_code}"
        task = resp.json()
        return task if isinstance(task, dict) else "task payload not an object"

    @staticmethod
    @staticmethod
    def _readiness_check_acceptance_criteria(task: dict[str, Any]) -> str | None:
        """Return blocker reason for missing acceptance criteria, else None."""
        criteria = task.get("acceptance_criteria") or []
        if isinstance(criteria, str):
            criteria = [criteria] if criteria.strip() else []
        if not criteria:
            return "missing acceptance_criteria"
        return None

    @staticmethod
    def _readiness_check_role_for_status(
        agent_id: str, role: str, status: str
    ) -> str | None:
        """Verify agent role matches the role expected for the task status."""
        role_mismatch: dict[str, str | set[str]] = {
            "awaiting_qa": "qa",
            "awaiting_documentation": "documenter",
            "awaiting_pm_review": {"cell_pm", "main_pm"},
            "awaiting_ceo_approval": "ceo",
        }
        required = role_mismatch.get(status)
        if required is None:
            return None
        ok = role in required if isinstance(required, set) else role == required
        if ok:
            return None
        return (
            f"state={status} requires role in {required!r} "
            f"but agent {agent_id} is {role!r}"
        )

    def _readiness_check_task(self, agent_id: str, task: dict[str, Any]) -> str | None:
        """Return a persistent blocker reason on the task itself, else None."""
        status = task.get("status", "")
        role = get_agent_role(agent_id) or ""

        if reason := self._readiness_check_acceptance_criteria(task):
            return reason
        if not _read_project_slug(task):
            return "task has no project"
        if status in {"claimed", "in_progress", "verifying"} and not task.get(
            "branch_name"
        ):
            return f"state={status} but branch_name is unset"
        return self._readiness_check_role_for_status(agent_id, role, status)

    @staticmethod
    async def _readiness_check_git_token(project_slug: str | None) -> str | None:
        """Ensure the project has a decryptable git token, else blocker reason."""
        if not project_slug:
            return "task has no project"
        from roboco.db.base import get_session_factory
        from roboco.services.project import get_project_service

        session_factory = get_session_factory()
        async with session_factory() as db:
            project_svc = get_project_service(db)
            try:
                token = await project_svc.get_decrypted_token_by_slug(project_slug)
            except Exception as e:
                return f"project '{project_slug}' git-token decrypt failed: {e}"
        if not token:
            return f"project '{project_slug}' has no git token configured"
        return None

    async def _readiness_block(
        self, client: httpx.AsyncClient, task_id: str, reason: str
    ) -> str:
        """Auto-block the task and return the human-readable reason."""
        await self._auto_block_task(client, task_id, f"readiness: {reason}")
        return reason

    async def _resolve_agent_route(self, agent_id: str) -> "AgentRoute":
        """Resolve (provider, model) for `agent_id` via `ModelRoutingService`.

        Errors are contained: any DB/session failure degrades to a legacy
        Anthropic-default AgentRoute so spawn never stalls on routing.
        """
        from roboco.db.base import get_session_factory
        from roboco.models.base import ModelProvider
        from roboco.models.runtime import MODEL_MAP
        from roboco.services.llm import (
            AgentRoute,
            get_model_routing_service,
        )

        try:
            factory = get_session_factory()
            async with factory() as db:
                router = get_model_routing_service(db)
                return await router.resolve_for_agent(agent_id)
        except Exception as e:  # pragma: no cover
            role = get_agent_role(agent_id) or ""
            short = ROLE_MODEL_MAP.get(role, "sonnet")
            logger.warning(
                "Model routing resolve failed; using legacy Anthropic path",
                agent_id=agent_id,
                error=str(e),
            )
            return AgentRoute(
                provider_id=None,
                provider_type=ModelProvider.ANTHROPIC,
                base_url=None,
                auth_token=None,
                model_name=MODEL_MAP.get(short, short),
            )

    _TOOL_LOAD_CACHE: ClassVar[dict[str, str]] = {}

    def _build_tool_load_block(self, role: str) -> str:
        """Build the mandatory first-action ToolSearch directive.

        Weak models consistently skip the role prompt's `Load on spawn
        (one ToolSearch select: call)` line — then trip on "Edit exists
        but is not enabled in this context" when they try to edit a
        file, or "No such tool available: mcp__roboco-flow__…" when
        they try to act. Hoisting the directive into the briefing's
        first block (with the exact query string inline) pulls the
        bootstrap into the prompt-most-salient position.

        Returns empty string if we can't locate the role file — the
        briefing still works without it.
        """
        if role in self._TOOL_LOAD_CACHE:
            return self._TOOL_LOAD_CACHE[role]
        block = self._read_tool_load_from_role_prompt(role)
        self._TOOL_LOAD_CACHE[role] = block
        return block

    def _read_tool_load_from_role_prompt(self, role: str) -> str:
        """Parse the `Load on spawn` line out of the role prompt."""
        role_file = self.project_root / "agents" / "prompts" / "roles" / f"{role}.md"
        if not role_file.exists():
            return ""
        try:
            text = role_file.read_text()
        except OSError:
            return ""
        marker = "## Load on spawn"
        idx = text.find(marker)
        if idx < 0:
            return ""
        # After the marker, the next line starts with a backtick-quoted list.
        tail = text[idx + len(marker) :]
        tick_start = tail.find("`")
        tick_end = tail.find("`", tick_start + 1)
        if tick_start < 0 or tick_end < 0:
            return ""
        tool_list = tail[tick_start + 1 : tick_end].strip()
        if not tool_list:
            return ""
        return (
            "## First action required\n"
            "Before any other tool call, run ToolSearch to enable the tools\n"
            "your role needs. Copy this verbatim as your first action:\n"
            "\n"
            f'```\nToolSearch(query="select:{tool_list}")\n```\n'
            "\n"
            "Skipping this step results in 'tool exists but is not enabled\n"
            "in this context' errors that waste tool-call budget.\n"
            "\n"
        )

    @staticmethod
    def _format_task_briefing_block(task_id: str, task: dict[str, Any]) -> str:
        """Build the ``## Current task`` markdown block from a fetched task."""
        criteria_list = task.get("acceptance_criteria") or []
        if isinstance(criteria_list, str):
            criteria_list = [criteria_list]
        criteria = (
            "\n".join(f"- {c}" for c in criteria_list)
            if criteria_list
            else "- (none listed — ask PM before proceeding)"
        )
        branch = task.get("branch_name") or "(to be created)"
        project_slug = task.get("project_slug") or "(unset — ask PM)"
        return (
            "\n## Current task\n"
            f"- **ID:** `{task.get('id', task_id)}`\n"
            f"- **Title:** {task.get('title', '(untitled)')}\n"
            f"- **Status:** {task.get('status', 'unknown')}\n"
            f"- **Type:** {task.get('task_type', 'unknown')}\n"
            f"- **Project slug:** `{project_slug}` "
            "(pass this as `project_slug=` on every git/task tool)\n"
            f"- **Branch:** `{branch}`\n"
            "\n### Acceptance criteria\n"
            f"{criteria}\n"
        )

    async def _fetch_task_for_briefing(
        self, agent_id: str, task_id: str
    ) -> dict[str, Any] | None:
        """Best-effort GET /tasks/{id}; returns task dict or None on failure."""
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                resp = await client.get(f"{self._api_url}/tasks/{task_id}")
            if resp.status_code == http_status.HTTP_200_OK:
                payload: dict[str, Any] = resp.json()
                return payload
        except Exception as e:
            logger.debug(
                "Briefing task-fetch failed — falling back to role-only",
                agent_id=agent_id,
                task_id=task_id,
                error=str(e),
            )
        return None

    async def _write_agent_briefing(
        self,
        agent_id: str,
        task_id: str | None,
        workspace_path: str,
    ) -> Path | None:
        """Write a compact task briefing to be read by SessionStart hook.

        The briefing saves the agent from burning its first 2-3 tool calls on
        `roboco_task_scan` + `roboco_task_get`. If `task_id` is known we fetch
        the task and include title, status, branch, and acceptance criteria.
        On fetch failure we still emit the role-level part (role, escalation
        target, terminal tools, workspace path) — strictly better than nothing.
        """
        role = get_agent_role(agent_id) or "agent"
        team = get_agent_team(agent_id) or "-"
        escalate_to = get_escalation_target(agent_id) or "main-pm"

        tool_load_block = self._build_tool_load_block(role)
        task_block = ""
        if task_id:
            task = await self._fetch_task_for_briefing(agent_id, task_id)
            if task is not None:
                task_block = self._format_task_briefing_block(task_id, task)

        content = (
            f"# Session briefing — {agent_id}\n"
            "\n"
            f"{tool_load_block}"
            "## You are\n"
            f"- **Agent:** `{agent_id}`\n"
            f"- **Role:** {role}\n"
            f"- **Team:** {team}\n"
            f"- **Escalate to:** `{escalate_to}`\n"
            f"- **Workspace:** `{workspace_path}`\n"
            f"{task_block}"
            "\n## Terminal tools (how to exit cleanly)\n"
            "- `roboco_agent_idle()` — no work remaining\n"
            "- `roboco_task_substitute(reason=...)` — release the task\n"
            "- `roboco_task_escalate(reason=...)` — escalate up the chain\n"
            "- `roboco_task_pause(checkpoint=...)` — save progress, resume later\n"
            "- Role handoffs: `roboco_task_submit_qa()`, `_qa_pass/fail()`, "
            "`_docs_complete()`, `_complete()`\n"
            "\n"
            "A Stop without a terminal tool will be rejected; a second Stop\n"
            "auto-substitutes with `reason='stopped_without_transition'`.\n"
            "\n"
            "## Budget\n"
            f"Soft-warn at {settings.agent_tool_call_warn} tool calls, "
            f"hard cap at {settings.agent_tool_call_halt}. Loops — same "
            f"tool+args {settings.agent_loop_threshold}x within "
            f"{settings.agent_loop_window} calls — are flagged; stop and "
            "escalate instead of retrying.\n"
        )

        if PROJECT_HOST_PATH:
            briefings_dir = Path("/app/briefings")
        else:
            briefings_dir = Path(tempfile.gettempdir()) / "roboco-briefings"
        briefings_dir.mkdir(parents=True, exist_ok=True)

        path = briefings_dir / f"{agent_id}.md"
        path.write_text(content)
        logger.debug(
            "Wrote agent briefing",
            agent_id=agent_id,
            path=str(path),
            has_task=bool(task_block),
        )
        return path

    def _get_blueprint_path(self, agent_id: str) -> Path:
        """Get blueprint path for an agent.

        DEPRECATED: Use _generate_composed_prompt() instead.
        Kept for backwards compatibility.
        """
        role = self._get_blueprint_role(agent_id)
        team = self._get_agent_team(agent_id)

        if team == "backend":
            cell_dir = "backend"
        elif team == "frontend":
            cell_dir = "frontend"
        elif team == "ux_ui":
            cell_dir = "ux_ui"
        else:
            cell_dir = "board"

        blueprint_file = f"{role.replace('_', '-')}.md"
        return self.blueprints_dir / cell_dir / blueprint_file

    def _get_blueprint_rel_path(self, agent_id: str) -> str:
        """Get relative blueprint path for container mount."""
        role = self._get_blueprint_role(agent_id)
        team = self._get_agent_team(agent_id)

        if team == "backend":
            cell_dir = "backend"
        elif team == "frontend":
            cell_dir = "frontend"
        elif team == "ux_ui":
            cell_dir = "ux_ui"
        else:
            cell_dir = "board"

        blueprint_file = f"{role.replace('_', '-')}.md"
        return f"{cell_dir}/{blueprint_file}"

    def _get_blueprint_role(self, agent_id: str) -> str:
        """Get blueprint-specific role name from agent_id (used for file paths)."""
        role_map = {
            "be-dev-1": "be-dev",
            "be-dev-2": "be-dev",
            "fe-dev-1": "fe-dev",
            "fe-dev-2": "fe-dev",
            "ux-dev-1": "ux-dev",
            "ux-dev-2": "ux-dev",
            "be-qa": "be-qa",
            "fe-qa": "fe-qa",
            "ux-qa": "ux-qa",
            "be-pm": "be-pm",
            "fe-pm": "fe-pm",
            "ux-pm": "ux-pm",
            "be-doc": "be-documenter",
            "fe-doc": "fe-documenter",
            "ux-doc": "ux-documenter",
            "main-pm": "main-pm",
            "product-owner": "product-owner",
            "head-marketing": "head-marketing",
            "auditor": "auditor",
        }
        return role_map.get(agent_id, agent_id)

    # Slug -> team string for ROUTING purposes. Derived from
    # foundation.AGENTS so adding/renaming an agent edits exactly one
    # file (foundation/identity.py). The dispatcher relies on this for
    # task assignment routing categories.
    _AGENT_TEAM_MAP: ClassVar[dict[str, str]] = {
        slug: row.team.value for slug, row in _foundation.AGENTS.items()
    }

    def _get_agent_team(self, agent_id: str) -> str | None:
        """Get team from agent_id. Returns None for unknown slugs."""
        try:
            return _foundation.team_for_slug(agent_id).value
        except KeyError:
            return None

    def _resolve_agent_slug(self, agent_id_or_uuid: str) -> str:
        """Resolve agent UUID to slug. Returns input if already a slug."""
        # Check if it's a known UUID and convert to slug
        if agent_id_or_uuid in UUID_TO_SLUG:
            return UUID_TO_SLUG[agent_id_or_uuid]
        # Already a slug or unknown UUID
        return agent_id_or_uuid

    def _mark_task_handled(self, task_id: str | None) -> None:
        """Record that `task_id` was acted on earlier in this dispatch tick."""
        if task_id:
            self._tick_handled_tasks.add(task_id)

    def _is_task_handled_this_tick(self, task_id: str | None) -> bool:
        """True if a prior dispatcher already handled this task this tick."""
        return bool(task_id and task_id in self._tick_handled_tasks)

    def _is_parallel_phase_claim(
        self, task: dict[str, Any], dev_uuid: str | None
    ) -> bool:
        """True if a `claimed` task is actually in the doc/PR parallel phase.

        The `original_developer:` quick_context marker is set pre-QA by
        `open_pr`, so it alone cannot distinguish a QA-claimed
        awaiting_qa task (wrong) from a doc-claimed awaiting_documentation
        task (right). Require the claimant to be a documenter.
        """
        if not dev_uuid:
            return False
        claimed_by = task.get("claimed_by")
        if not claimed_by:
            return False
        claimed_slug = self._resolve_agent_slug(claimed_by)
        return bool(claimed_slug) and "doc" in claimed_slug

    async def _respawn_dev_for_pr_half(
        self, task: dict[str, Any], dev_uuid: str | None
    ) -> None:
        """Respawn the original developer if they still owe the PR half.

        `pr_number` is set by the PR-create handler as soon as GitHub
        confirms the PR, even if the status-gated `pr_created` flag never
        flips — without that second check we'd respawn the dev forever
        after they've already created the PR (the handler refuses to set
        pr_created=True when the doc's claim moved status out of
        awaiting_documentation).
        """
        if not dev_uuid or task.get("pr_created") or task.get("pr_number"):
            return
        dev_slug = self._resolve_agent_slug(dev_uuid)
        if not dev_slug or self._is_agent_active(dev_slug):
            return
        await self.spawn_agent(
            agent_id=dev_slug,
            task_id=task["id"],
            initial_prompt=self._build_dev_prompt(task),
            git_context=self._task_git_context(task),
        )

    # =========================================================================
    # AGENT STOPPING
    # =========================================================================

    async def stop_agent(self, agent_id: str, graceful: bool = True) -> None:
        """Stop an agent container."""
        async with self._lock:
            if agent_id not in self._instances:
                return

            instance = self._instances[agent_id]

            if instance.container_id:
                instance.state = AgentState.STOPPING
                container_name = f"roboco-agent-{agent_id}"

                if graceful:
                    # Graceful stop with timeout
                    proc = await asyncio.create_subprocess_exec(
                        "docker",
                        "stop",
                        "-t",
                        "10",
                        container_name,
                        stdout=asyncio.subprocess.DEVNULL,
                        stderr=asyncio.subprocess.DEVNULL,
                    )
                    await proc.wait()
                else:
                    # Force kill
                    proc = await asyncio.create_subprocess_exec(
                        "docker",
                        "kill",
                        container_name,
                        stdout=asyncio.subprocess.DEVNULL,
                        stderr=asyncio.subprocess.DEVNULL,
                    )
                    await proc.wait()

                # Remove container
                await self._remove_container(container_name)

            instance.state = AgentState.OFFLINE
            instance.container_id = None

            logger.info("Agent stopped", agent_id=agent_id)

    # =========================================================================
    # WAITING STATE MANAGEMENT
    # =========================================================================

    async def mark_waiting_long(
        self,
        agent_id: str,
        waiting_for: str,
        task_id: str | None = None,
        context: dict[str, Any] | None = None,
    ) -> None:
        """
        Mark an agent as WAITING_LONG and terminate.

        The agent will be respawned when the wait condition is resolved.
        The record is mirrored to `waiting_records` in Postgres so a later
        orchestrator restart can still resolve the wait.
        """
        record = WaitingRecord(
            agent_id=agent_id,
            task_id=task_id,
            waiting_for=waiting_for,
            waiting_since=datetime.now(UTC),
            context=context or {},
        )

        self._waiting_records[agent_id] = record
        await self._persist_waiting_record(record)

        # Stop the agent
        await self.stop_agent(agent_id)

        # Update state
        if agent_id in self._instances:
            self._instances[agent_id].state = AgentState.WAITING_LONG
            self._instances[agent_id].waiting_for = waiting_for
            self._instances[agent_id].waiting_context = context or {}

        logger.info(
            "Agent marked as waiting_long",
            agent_id=agent_id,
            waiting_for=waiting_for,
            task_id=task_id,
        )

    async def _persist_waiting_record(self, record: WaitingRecord) -> None:
        """Upsert a WaitingRecord into the waiting_records table."""
        try:
            from uuid import UUID as _UUID

            from sqlalchemy import delete

            from roboco.db.base import get_session_factory
            from roboco.db.tables import WaitingRecordTable

            session_factory = get_session_factory()
            async with session_factory() as db:
                # One record per agent; delete prior then insert.
                await db.execute(
                    delete(WaitingRecordTable).where(
                        WaitingRecordTable.agent_id == record.agent_id
                    )
                )
                row = WaitingRecordTable(
                    agent_id=record.agent_id,
                    task_id=(_UUID(record.task_id) if record.task_id else None),
                    waiting_for=record.waiting_for,
                    waiting_since=record.waiting_since,
                    context=record.context,
                )
                db.add(row)
                await db.commit()
        except Exception as e:
            logger.error(
                "Failed to persist waiting record",
                agent_id=record.agent_id,
                error=str(e),
            )

    async def _delete_waiting_record(self, agent_id: str) -> None:
        """Delete a persisted waiting record when its wait resolves."""
        try:
            from sqlalchemy import delete

            from roboco.db.base import get_session_factory
            from roboco.db.tables import WaitingRecordTable

            session_factory = get_session_factory()
            async with session_factory() as db:
                await db.execute(
                    delete(WaitingRecordTable).where(
                        WaitingRecordTable.agent_id == agent_id
                    )
                )
                await db.commit()
        except Exception as e:
            logger.error(
                "Failed to delete waiting record",
                agent_id=agent_id,
                error=str(e),
            )

    async def restore_waiting_records(self) -> int:
        """Load persisted waiting records into memory on orchestrator start.

        Call this from `start()` so agents marked WAITING_LONG before the
        previous orchestrator exited can still be resolved.
        """
        try:
            from sqlalchemy import select

            from roboco.db.base import get_session_factory
            from roboco.db.tables import WaitingRecordTable

            session_factory = get_session_factory()
            async with session_factory() as db:
                rows = await db.execute(select(WaitingRecordTable))
                count = 0
                for row in rows.scalars().all():
                    self._waiting_records[row.agent_id] = WaitingRecord(
                        agent_id=row.agent_id,
                        task_id=str(row.task_id) if row.task_id else None,
                        waiting_for=row.waiting_for,
                        waiting_since=row.waiting_since,
                        context=dict(row.context or {}),
                    )
                    count += 1
                if count:
                    logger.info(
                        "Restored waiting records from database",
                        count=count,
                    )
                return count
        except Exception as e:
            logger.error("Failed to restore waiting records", error=str(e))
            return 0

    async def resolve_wait(
        self,
        agent_id: str,
        resolution: dict[str, Any],
    ) -> AgentInstance | None:
        """
        Resolve a wait condition and respawn the agent.

        Args:
            agent_id: The waiting agent
            resolution: Details about the resolution

        Returns:
            Respawned AgentInstance or None
        """
        if agent_id not in self._waiting_records:
            return None

        record = self._waiting_records[agent_id]
        del self._waiting_records[agent_id]
        await self._delete_waiting_record(agent_id)

        # Generate resume prompt
        resume_prompt = self._generate_resume_prompt(record, resolution)

        # Preserve the original git_context from the prior instance so the
        # respawned agent keeps the same workspace mount path.
        prior = self._instances.get(agent_id)
        prior_git_context = prior.config.git_context if prior and prior.config else None

        # Respawn
        return await self.spawn_agent(
            agent_id=agent_id,
            initial_prompt=resume_prompt,
            task_id=record.task_id,
            git_context=prior_git_context,
        )

    def _generate_resume_prompt(
        self,
        record: WaitingRecord,
        resolution: dict[str, Any],
    ) -> str:
        """Generate a resume prompt for a respawning agent."""
        if record.waiting_for == "blocker_resolution":
            return f"""
You were working on TASK-{record.task_id} and got blocked.
The blocker has been resolved: {resolution.get("details", "Resolved")}

Resume by:
1. Reading your checkpoint from .tasks/active/TASK-{record.task_id}/
2. Call roboco_task_unblock("{record.task_id}")
3. Continue from where you left off
"""

        elif record.waiting_for == "qa_result":
            if resolution.get("passed"):
                return f"""
TASK-{record.task_id} has passed QA review.
The task is now awaiting documentation.
You may return to scanning for new work with roboco_task_scan().
"""
            else:
                return f"""
TASK-{record.task_id} needs revision based on QA feedback.
QA notes: {resolution.get("notes", "See task for details")}

Resume by:
1. Reading the QA feedback
2. Updating your TODOs to address each issue
3. Making the fixes
4. Re-submitting for QA
"""

        elif record.waiting_for == "answer":
            return f"""
You asked a question about TASK-{record.task_id}:
Your question: {record.context.get("question", "Unknown")}
Answer received: {resolution.get("answer", "Unknown")}

Resume by incorporating this information and continuing from where you stopped.
"""

        elif record.waiting_for == "assignment":
            return f"""
You have been assigned a new task: TASK-{resolution.get("task_id")}

Start by:
1. Call roboco_task_get("{resolution.get("task_id")}") to get details
2. Follow the standard workflow: UNDERSTAND → PLAN → EXECUTE → VERIFY → NOTES
"""

        else:
            return f"Resuming. Wait condition '{record.waiting_for}' resolved."

    # =========================================================================
    # HEALTH MONITORING
    # =========================================================================

    async def _health_loop(self) -> None:
        """Background health check loop."""
        while self._running:
            try:
                await asyncio.sleep(30)  # Check every 30 seconds
                await self._check_health()
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error("Health check error", error=str(e))

    async def _sweeper_loop(self) -> None:
        """Background sweeper for session timeouts and stale notifications.

        Addresses two silent-failure surfaces:
        - SessionTable.timeout_seconds / max_time_window were never enforced;
          sessions stayed ACTIVE forever.
        - NotificationTable.expires_at existed but no job ever acted on it.

        Runs on its own interval so a slow sweep can't delay agent dispatch.
        """
        sweep_interval = 60  # seconds
        while self._running:
            try:
                await asyncio.sleep(sweep_interval)
                await self._run_sweep()
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error("Sweeper loop error", error=str(e))

    async def _run_sweep(self) -> None:
        """Run one pass of session + notification sweepers."""
        from roboco.db.base import get_session_factory
        from roboco.services.messaging import get_messaging_service
        from roboco.services.notification_delivery import (
            get_notification_delivery_service,
        )

        session_factory = get_session_factory()
        async with session_factory() as db:
            msg_svc = get_messaging_service(db)
            try:
                closed = await msg_svc.sweep_timed_out_sessions()
                if closed:
                    await db.commit()
            except Exception as e:
                await db.rollback()
                logger.warning("Session sweep failed", error=str(e))

            deliv_svc = get_notification_delivery_service(db)
            try:
                expired = await deliv_svc.sweep_expired_notifications()
                if expired:
                    await db.commit()
            except Exception as e:
                await db.rollback()
                logger.warning("Notification sweep failed", error=str(e))

        # Budget kill-switch — runs every sweep. Any agent whose SDK reports
        # halt=true has breached its per-session tool-call cap; terminate the
        # container so the next dispatcher tick doesn't waste tokens on the
        # same session.
        await self._sweep_budget_exceeded()

    async def _sweep_budget_exceeded(self) -> None:
        """Stop agents whose per-session SDK budget reports halt=true.

        Each agent's SDK server is reachable at
        `http://roboco-agent-{agent_id}:9000/budget/status` on the shared
        agent network. A budget-exceeded agent gets a forced stop with a
        `budget_exceeded` reason; the task is already being auto-substituted
        by the post-tool hook on the agent side.
        """
        if not self._instances:
            return
        async with httpx.AsyncClient(timeout=3.0) as client:
            for agent_id, instance in list(self._instances.items()):
                if instance.state not in (
                    AgentState.ACTIVE,
                    AgentState.WAITING_SHORT,
                ):
                    continue
                url = f"http://roboco-agent-{agent_id}:9000/budget/status"
                try:
                    resp = await client.get(url)
                    if resp.status_code != http_status.HTTP_200_OK:
                        continue
                    data = resp.json()
                except Exception:
                    # SDK unreachable / not yet started / container gone —
                    # either benign or covered by health loop.
                    continue
                if not data.get("halt"):
                    continue
                logger.warning(
                    "Agent budget exceeded; terminating container",
                    agent_id=agent_id,
                    total_calls=data.get("total"),
                    halt_threshold=data.get("halt_threshold"),
                )
                try:
                    await self.stop_agent(agent_id, graceful=True)
                except Exception as e:
                    logger.warning(
                        "Failed to stop budget-exceeded agent",
                        agent_id=agent_id,
                        error=str(e),
                    )

    async def _check_health(self) -> None:
        """Check health of all running agents."""
        for agent_id, instance in list(self._instances.items()):
            if instance.state not in (AgentState.ACTIVE, AgentState.WAITING_SHORT):
                continue

            if instance.container_id is None:
                continue

            # Check if container is still running
            container_name = f"roboco-agent-{agent_id}"
            proc = await asyncio.create_subprocess_exec(
                "docker",
                "inspect",
                "-f",
                "{{.State.Running}}",
                container_name,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.DEVNULL,
            )
            stdout, _ = await proc.communicate()

            is_running = stdout.decode().strip() == "true"

            if not is_running:
                cid = instance.container_id[:12] if instance.container_id else None
                logger.warning(
                    "Agent container stopped",
                    agent_id=agent_id,
                    container_id=cid,
                )
                instance.state = AgentState.OFFLINE
                instance.error_count += 1
                instance.container_id = None

                # Auto-restart if not too many errors
                max_retries = 3
                if instance.error_count < max_retries:
                    logger.info("Auto-restarting agent", agent_id=agent_id)
                    await self.spawn_agent(
                        agent_id=agent_id,
                        task_id=instance.current_task_id,
                        git_context=(
                            instance.config.git_context if instance.config else None
                        ),
                    )
                elif instance.error_count == max_retries:
                    # Exactly at the threshold — escalate once to humans so a
                    # stranded agent doesn't die silently. Subsequent crashes
                    # stay quiet to avoid notification spam.
                    logger.error(
                        "Agent exceeded max restart attempts; escalating",
                        agent_id=agent_id,
                        error_count=instance.error_count,
                        task_id=instance.current_task_id,
                    )
                    await self._notify_agent_stranded(
                        agent_id=agent_id,
                        error_count=instance.error_count,
                        task_id=instance.current_task_id,
                    )

    async def _notify_agent_stranded(
        self,
        agent_id: str,
        error_count: int,
        task_id: str | None,
    ) -> None:
        """Create a notification for humans when an agent can't be restarted.

        Posts a high-priority notification addressed to the auditor and CEO.
        Fire-and-forget: the agent is already dead; don't let our own failure
        stop the health loop.
        """
        try:
            from sqlalchemy import select

            from roboco.db.base import get_session_factory
            from roboco.db.tables import AgentTable, NotificationTable
            from roboco.models.base import (
                AgentRole,
                NotificationPriority,
                NotificationType,
            )
            from roboco.services.notification_delivery import (
                get_notification_delivery_service,
            )
            from roboco.utils.converters import require_uuid

            session_factory = get_session_factory()
            async with session_factory() as db:
                orch_agent = await db.execute(
                    select(AgentTable).where(AgentTable.role == AgentRole.AUDITOR)
                )
                auditor = orch_agent.scalar_one_or_none()
                ceo_result = await db.execute(
                    select(AgentTable).where(AgentTable.role == AgentRole.CEO)
                )
                ceo = ceo_result.scalar_one_or_none()
                recipients = [a.id for a in (auditor, ceo) if a is not None]
                if not recipients:
                    logger.warning(
                        "No auditor/ceo found for stranded-agent notification",
                        agent_id=agent_id,
                    )
                    return
                from_agent = auditor.id if auditor else ceo.id  # type: ignore[union-attr]
                notification = NotificationTable(
                    type=NotificationType.ALERT,
                    priority=NotificationPriority.HIGH,
                    from_agent=from_agent,
                    to_agents=recipients,
                    subject=f"Agent stranded: {agent_id}",
                    body=(
                        f"Agent '{agent_id}' exceeded max restart attempts "
                        f"({error_count}) and will not auto-recover. "
                        f"Task: {task_id or 'none'}. Manual intervention needed."
                    ),
                    requires_ack=True,
                )
                db.add(notification)
                await db.flush()
                delivery = get_notification_delivery_service(db)
                await delivery.deliver(require_uuid(notification.id))
                await db.commit()
        except Exception as e:
            logger.error(
                "Failed to send stranded-agent notification",
                agent_id=agent_id,
                error=str(e),
            )

    # =========================================================================
    # STATUS API
    # =========================================================================

    def get_state(self, agent_id: str) -> AgentState:
        """Get current state of an agent."""
        if agent_id not in self._instances:
            return AgentState.OFFLINE
        return self._instances[agent_id].state

    def get_instance(self, agent_id: str) -> AgentInstance | None:
        """Get instance for an agent."""
        return self._instances.get(agent_id)

    def get_all_instances(self) -> dict[str, AgentInstance]:
        """Get all agent instances."""
        return dict(self._instances)

    def get_waiting_agents(self) -> dict[str, WaitingRecord]:
        """Get all waiting agents."""
        return dict(self._waiting_records)

    def get_status_summary(self) -> dict[str, Any]:
        """Get summary of all agent states."""
        by_state: dict[str, int] = {}
        agents: list[dict[str, Any]] = []

        for state in AgentState:
            count = sum(1 for i in self._instances.values() if i.state == state)
            if count > 0:
                by_state[state.value] = count

        for agent_id, instance in self._instances.items():
            cid = instance.container_id[:12] if instance.container_id else None
            agents.append(
                {
                    "agent_id": agent_id,
                    "state": instance.state.value,
                    "container_id": cid,
                    "task_id": instance.current_task_id,
                    "error_count": instance.error_count,
                    "started_at": instance.started_at.isoformat()
                    if instance.started_at
                    else None,
                }
            )

        return {
            "total": len(self._instances),
            "by_state": by_state,
            "waiting_count": len(self._waiting_records),
            "agents": agents,
        }

    # =========================================================================
    # SMART DISPATCHER - API HELPERS
    # =========================================================================

    @property
    def _api_url(self) -> str:
        """Get the internal API URL for task/notification queries."""
        return settings.internal_api_url

    def _is_agent_active(self, agent_id: str) -> bool:
        """Check if an agent is currently running."""
        if agent_id not in self._instances:
            return False
        return self._instances[agent_id].state == AgentState.ACTIVE

    async def _check_parent_branch_ready(
        self, client: httpx.AsyncClient, task_id: str, parent_id: str
    ) -> str | None:
        """Verify the parent task has a branch; auto-block + return msg if not.

        Race window: the PM's `i_will_plan` claims the parent (transitions
        status -> in_progress, sets assigned_to) and then `_finalize_claim`
        creates the branch via `_ensure_branch_for_task`. Both actions land
        in the same DB transaction but a child dev's spawn dispatch can fire
        microseconds before that transaction commits and see branch_name=None.
        Without retry we'd auto-block the child unnecessarily.

        When the parent is clearly mid-claim (in_progress + assigned_to set)
        re-fetch up to 3 times with a 250ms delay before giving up. Total
        worst-case wait is 750ms — well inside the dispatcher's tick budget
        and only paid when the race actually triggers. Real misses (parent
        still pending or unassigned) auto-block immediately as before.
        """
        parent_resp = await client.get(f"{self._api_url}/tasks/{parent_id}")
        if not parent_resp.is_success:
            return None
        parent = parent_resp.json()
        if parent.get("branch_name"):
            return None

        if parent.get("status") == "in_progress" and parent.get("assigned_to"):
            for _ in range(3):
                await asyncio.sleep(0.25)
                parent_resp = await client.get(f"{self._api_url}/tasks/{parent_id}")
                if not parent_resp.is_success:
                    continue
                parent = parent_resp.json()
                if parent.get("branch_name"):
                    return None

        await self._auto_block_task(
            client,
            task_id,
            "Parent task must be claimed first to create its branch",
        )
        return f"Task {task_id} waiting for parent branch"

    async def _check_dev_needs_subtasks(
        self, client: httpx.AsyncClient, task: dict[str, Any]
    ) -> str | None:
        """Block non-trivial root tasks routed to a dev without subtasks."""
        complexity = task.get("estimated_complexity", "low")
        parent_task_id = task.get("parent_task_id")
        if complexity not in ("medium", "high") or parent_task_id:
            return None
        task_id = task.get("id")
        try:
            resp = await client.get(f"{self._api_url}/tasks/{task_id}/subtasks")
            subtasks = resp.json() if resp.is_success else []
        except Exception:
            subtasks = []
        if subtasks:
            return None
        await self._auto_block_task(
            client,
            str(task_id),
            f"Task complexity is {complexity} but no subtasks. "
            "Cell PM must break down work first.",
        )
        return (
            f"Task {task_id} is {complexity} complexity "
            "without subtasks - Cell PM must break it down"
        )

    async def _validate_task_for_spawn(
        self,
        client: httpx.AsyncClient,
        task: dict,
        agent_slug: str,
    ) -> str | None:
        """
        Validate task is ready for agent spawn.

        Returns None if valid, or error message if task cannot proceed.
        This prevents spawning agents on tasks that are missing prerequisites.
        """
        from roboco.agents_config import get_agent_role

        task_id = task.get("id")
        if not task_id:
            return "Task missing ID"
        min_description_len = 10

        description = (task.get("description") or "").strip()
        if len(description) < min_description_len:
            return (
                f"Task {task_id} has inadequate description ({len(description)} chars)"
            )

        if not task.get("project_id"):
            await self._auto_block_task(client, task_id, "Task needs project_id")
            return f"Task {task_id} needs project"

        parent_id = task.get("parent_task_id")
        if parent_id:
            err = await self._check_parent_branch_ready(client, task_id, parent_id)
            if err:
                return err

        logger.info("Task ready for hierarchical branch creation", task_id=task_id)

        if get_agent_role(agent_slug) == "developer":
            err = await self._check_dev_needs_subtasks(client, task)
            if err:
                return err

        return None  # All validations passed

    async def _auto_block_task(
        self, client: httpx.AsyncClient, task_id: str, reason: str
    ) -> None:
        """Auto-block a task that cannot proceed due to missing prerequisites."""
        try:
            await client.patch(
                f"{self._api_url}/tasks/{task_id}",
                json={
                    "status": "blocked",
                    "dev_notes": f"[AUTO-BLOCKED] {reason}",
                },
            )
            logger.info(
                "Auto-blocked task with missing prerequisites",
                task_id=task_id,
                reason=reason,
            )
        except Exception as e:
            logger.error(
                "Failed to auto-block task",
                task_id=task_id,
                error=str(e),
            )

    def _select_agent_for_cell(self, cell: str, role: str) -> str | None:
        """
        Select the best available agent for a cell and role.

        Prefers agents that are not currently active.
        For developers, uses round-robin among candidates.
        """
        prefix_map = {"backend": "be", "frontend": "fe", "ux_ui": "ux"}
        prefix = prefix_map.get(cell)
        if not prefix:
            return None

        # Build candidate list based on role
        if role == "dev":
            candidates = [f"{prefix}-dev-1", f"{prefix}-dev-2"]
        elif role == "qa":
            candidates = [f"{prefix}-qa"]
        elif role == "doc":
            candidates = [f"{prefix}-doc"]
        elif role == "pm":
            candidates = [f"{prefix}-pm"]
        else:
            return None

        # Prefer non-active agents
        for agent_id in candidates:
            if not self._is_agent_active(agent_id):
                return agent_id

        # All active - return first (task will queue for them via scan)
        return candidates[0]

    async def _claim_task_for_agent(
        self,
        client: httpx.AsyncClient,
        task_id: str,
        agent_id: str,
    ) -> bool:
        """Claim a task on behalf of an agent before spawning."""
        try:
            resp = await client.post(
                f"{self._api_url}/tasks/{task_id}/claim",
                json={"agent_id": agent_id},
            )
            if resp.status_code == http_status.HTTP_200_OK:
                logger.info(
                    "Task claimed for agent",
                    task_id=task_id,
                    agent_id=agent_id,
                )
                return True
            logger.warning(
                "Failed to claim task",
                task_id=task_id,
                agent_id=agent_id,
                status=resp.status_code,
            )
        except Exception as e:
            logger.error("Claim task error", task_id=task_id, error=str(e))
        return False

    async def _fetch_tasks(
        self,
        client: httpx.AsyncClient,
        status: str | list[str],
        team: str | None = None,
    ) -> list[dict[str, Any]]:
        """Fetch tasks by status and optional team filter."""
        # If multiple statuses, make separate requests and combine results
        statuses = status if isinstance(status, list) else [status]
        all_tasks: list[dict[str, Any]] = []

        for single_status in statuses:
            params: dict[str, Any] = {"status": single_status}
            if team:
                params["team"] = team

            try:
                resp = await client.get(f"{self._api_url}/tasks", params=params)
                if resp.status_code == http_status.HTTP_200_OK:
                    tasks: list[dict[str, Any]] = resp.json()
                    all_tasks.extend(tasks)
            except Exception as e:
                logger.error(
                    "Fetch tasks error", status=single_status, team=team, error=str(e)
                )

        return all_tasks

    async def _fetch_notifications(
        self,
        client: httpx.AsyncClient,
        notification_type: str,
        unacknowledged: bool = True,
    ) -> list[dict[str, Any]]:
        """Fetch notifications by type."""
        params: dict[str, Any] = {
            "type_filter": notification_type,
            "pending_ack_only": str(unacknowledged).lower(),
        }
        try:
            resp = await client.get(
                f"{self._api_url}/notifications",
                params=params,
            )
            if resp.status_code == http_status.HTTP_200_OK:
                data = resp.json()
                items: list[dict[str, Any]] = data.get("items", [])
                return items
        except Exception as e:
            logger.error(
                "Fetch notifications error",
                notification_type=notification_type,
                error=str(e),
            )
        return []

    # =========================================================================
    # SMART ROUTING - TASK CLASSIFICATION
    # =========================================================================

    # Keywords that indicate strategic/board-level tasks
    _BOARD_KEYWORDS = frozenset(
        {
            "roadmap",
            "architecture",
            "security",
            "budget",
            "hiring",
            "strategy",
            "vision",
            "milestone",
            "release",
            "launch",
        }
    )

    # Keywords that indicate PM coordination is needed
    _PM_KEYWORDS = frozenset(
        {
            "coordinate",
            "integration",
            "cross-team",
            "sync",
            "planning",
            "milestone",
            "dependencies",
            "review",
        }
    )

    # Keywords that indicate cross-cell work (requires Main PM)
    _CROSS_CELL_KEYWORDS = frozenset(
        {
            "all teams",
            "all cells",
            "every team",
            "every cell",
            "all departments",
            "cross-cell",
            "company-wide",
            "organization-wide",
            "backend and frontend",
            "frontend and backend",
            "all three",
        }
    )

    def _has_board_keywords(self, text: str) -> bool:
        """Check if text contains board-level keywords."""
        return any(kw in text for kw in self._BOARD_KEYWORDS)

    def _has_pm_keywords(self, text: str) -> bool:
        """Check if text contains PM coordination keywords."""
        return any(kw in text for kw in self._PM_KEYWORDS)

    def _has_cross_cell_keywords(self, text: str) -> bool:
        """Check if text indicates work spanning multiple cells."""
        return any(kw in text for kw in self._CROSS_CELL_KEYWORDS)

    # Direct team-to-routing mappings (explicit assignments bypass keyword analysis)
    _TEAM_ROUTING_MAP: ClassVar[dict[str, str]] = {
        "main_pm": "main_pm",
        "board": "board",
        "marketing": "marketing",
    }

    @staticmethod
    def _route_by_task_type(task_type: str, team: str | None) -> str | None:
        """Route based on task_type field alone; returns None if no match."""
        cell_teams = ("backend", "frontend", "ux_ui")
        if task_type in ("planning", "research", "administrative"):
            return "cell_pm" if team in cell_teams else "main_pm"
        if task_type == "design" and team not in ("backend", "frontend"):
            return "cell_pm"
        return None

    def _classify_code_task(self, task: dict[str, Any]) -> str:
        """Classify a generic `code` task via keyword/complexity heuristics."""
        team = task.get("team")
        title = (task.get("title") or "").lower()
        description = (task.get("description") or "").lower()
        text = f"{title} {description}"
        complexity = task.get("estimated_complexity", "medium").lower()

        if self._has_board_keywords(text):
            return "board"

        if (
            self._has_cross_cell_keywords(text)
            or complexity == "high"
            or not team
            or team == "all"
        ):
            return "main_pm"

        if self._has_pm_keywords(text) or complexity == "medium":
            return "cell_pm"

        return "dev"

    def _classify_task_routing(self, task: dict[str, Any]) -> str:
        """
        Classify a task for routing based on task_type, team, complexity, and keywords.

        Returns one of: "board", "main_pm", "cell_pm", "dev", "marketing"
        """
        team = task.get("team")
        task_type = task.get("task_type", "code")

        # Task type takes precedence for non-code work
        by_type = self._route_by_task_type(task_type, team)
        if by_type:
            return by_type
        if team in self._TEAM_ROUTING_MAP:
            return self._TEAM_ROUTING_MAP[team]

        return self._classify_code_task(task)

    # Team to PM mapping for routing
    _TEAM_PM_MAP: ClassVar[dict[str, str]] = {
        "backend": "be-pm",
        "frontend": "fe-pm",
        "ux_ui": "ux-pm",
    }

    def _get_routing_target(self, routing: str, task: dict[str, Any]) -> str | None:
        """
        Resolve a routing decision to a specific agent slug.

        Args:
            routing: One of "board", "main_pm", "cell_pm", "dev", "marketing"
            task: The task being routed

        Returns:
            Agent slug (e.g., "main-pm", "be-pm", "be-dev-1") or None
        """
        team = task.get("team")

        # Static routing targets
        static_targets = {
            "board": "product-owner",
            "main_pm": "main-pm",
            "marketing": "head-marketing",
        }
        if routing in static_targets:
            return static_targets[routing]

        # Cell PM routing - requires team lookup
        if routing == "cell_pm":
            return self._TEAM_PM_MAP.get(team, "main-pm") if team else "main-pm"

        # Dev routing - requires agent selection
        if routing == "dev" and team:
            return self._select_agent_for_cell(team, "dev")

        return None

    def _build_main_pm_triage_prompt(self, task: dict[str, Any]) -> str:
        """Build prompt for MAIN PM to triage and distribute to Cell PMs."""
        task_id = task.get("id", "unknown")
        title = task.get("title", "Untitled")
        complexity = task.get("complexity", "medium")
        description = task.get("description", "")

        return f"""You are the MAIN PM at RoboCo. This task is assigned to YOU.

TASK: {task_id}
TITLE: {title}
COMPLEXITY: {complexity}
DESCRIPTION: {description[:500]}

YOUR JOB: Break this down and delegate to Cell PMs. You do NOT implement
code. You do NOT assign directly to developers — Cell PMs manage their
teams. For purely-PM work (validation, announcements, cross-cell sync) you
may keep the task and work it via your gateway verbs.

== DELEGATION TARGETS ==

- Backend work → be-pm (who delegates to be-dev-1 / be-dev-2)
- Frontend work → fe-pm (who delegates to fe-dev-1 / fe-dev-2)
- UX/UI work → ux-pm (who delegates to ux-dev-1 / ux-dev-2)

NEVER assign to a dev slug from this seat — only Cell PM slugs.

== TOOLS ==

Gateway verbs (already loaded):
- evidence(task_id="{task_id}")            — inspect the task
- triage_all()                             — see what's pending across cells
- note(text, scope='decision', task_id="{task_id}")
    REQUIRED before i_will_plan / complete / escalate
- i_will_plan(task_id="{task_id}", plan="<your detailed plan as a string>")
    claim + record plan + start your own root task
- delegate(parent_task_id="{task_id}", title=..., description=...,
           assigned_to=<one of "be-pm" / "fe-pm" / "ux-pm">,
           team=<one of "backend" / "frontend" / "ux_ui">,
           task_type=<one of "code" / "documentation" / "research" /
                            "planning" / "design" / "administrative">,
           acceptance_criteria=[...],
           estimated_complexity=<one of "low" / "medium" / "high">)
    creates a subtask under your root and assigns it to a Cell PM.
    Use the EXACT enum strings above — invented values like
    "development" or "small" are rejected by the gateway. Repeat
    once per cell that needs work.
- unblock(task_id, restore=True)
- complete(task_id="{task_id}", notes=...)  for root awaiting_pm_review
- escalate_to_ceo(task_id="{task_id}", reason=...) for root tasks
- say(channel, text), dm(recipient, text)
- i_am_idle() — when delegated and waiting

== WORKFLOW ==

1. evidence(task_id="{task_id}")
2. note(scope='decision', task_id="{task_id}",
        text="<plan summary: cells X/Y get subtasks A/B>")
3. i_will_plan(task_id="{task_id}",
               plan="<detailed plan: scope, cell breakdown, sequencing, risks>")
4. delegate(parent_task_id="{task_id}", title="Backend slice of <root>",
            description="What be-pm should coordinate.",
            assigned_to="be-pm", team="backend", task_type="code",
            acceptance_criteria=["c1", "c2"], estimated_complexity="medium")
   — repeat per cell that needs work. ONE subtask per cell; the Cell PM
   breaks it down further.
5. say("#main-pm-board", "Delegated <root> to be-pm/fe-pm — see subtasks")
6. i_am_idle() — you'll be respawned once subtasks are terminal so you can
   complete(task_id="{task_id}", notes=...) or escalate_to_ceo on the root.

== RULES ==

- Never `commit`, never write code, never run `git`. PMs coordinate.
- Never assign a code subtask directly to a developer slug — always to a Cell PM.
- delegate / complete / escalate will fail unless you've logged a journal
  decision for this task — read the `remediate` field on errors.

Start now: evidence(task_id="{task_id}")
"""

    def _build_pm_triage_prompt(self, task: dict[str, Any]) -> str:
        """Build prompt for CELL PM to triage and delegate a task."""
        task_id = task.get("id", "unknown")
        title = task.get("title", "Untitled")
        complexity = task.get("complexity", "medium")
        team = task.get("team", "unknown")

        # Build team-specific info
        channel = f"{team}-cell" if team != "ux_ui" else "uxui-cell"
        dev_map = {
            "backend": ("be-dev-1", "be-dev-2"),
            "frontend": ("fe-dev-1", "fe-dev-2"),
            "ux_ui": ("ux-dev-1", "ux-dev-2"),
        }
        devs = dev_map.get(team, ("be-dev-1",))
        primary_dev = devs[0]
        dev_options = " or ".join(devs)

        return f"""You are the PM for {team} team. This task is assigned to YOU.

TASK: {task_id}
TITLE: {title}
COMPLEXITY: {complexity}
TEAM: {team}

YOUR JOB: Break this down into concrete subtasks and delegate each to a
developer in your cell. You do NOT code. You do NOT run git. You coordinate.

Available developers in your cell: {dev_options}

== TOOLS ==

Gateway verbs (already loaded):
- evidence(task_id="{task_id}")           — read PR + commits + diff
- triage()                                 — see what your cell needs next
- note(text, scope='decision', task_id="{task_id}")
    REQUIRED before i_will_plan / unblock / complete / escalate
- i_will_plan(task_id="{task_id}", plan="<detailed plan as a string>")
    claim + record plan + start your own cell-PM task
- delegate(parent_task_id="{task_id}", title=..., description=...,
           assigned_to=<dev slug in your cell, e.g. "be-dev-1">,
           team="{team}",
           task_type=<one of "code" / "documentation" / "research" /
                            "planning" / "design" / "administrative">,
           acceptance_criteria=[...],
           estimated_complexity=<one of "low" / "medium" / "high">)
    creates a subtask under your cell-PM task and assigns it to a developer.
    Use the EXACT enum strings above — invented values like
    "development" or "small" are rejected by the gateway.
    Repeat 2 to 5 times for focused subtasks.
- unblock(task_id, restore=True)
    when a dev signals i_am_blocked
- complete(task_id, notes)
    review a SUBTASK in awaiting_pm_review (auto-merges its leaf PR)
- submit_up(task_id="{task_id}", notes=...)
    when YOUR OWN cell-PM task's subtasks are all terminal: opens cell-level
    PR up to Main PM's branch and transitions to awaiting_pm_review.
- escalate_up(task_id, reason)            — to Main PM
- say("{channel}", text), dm(recipient, text)
- i_am_idle() — when delegated and waiting

== WORKFLOW ==

1. evidence(task_id="{task_id}")
2. note(scope='decision', task_id="{task_id}",
        text="<approach>; subtasks: A→{primary_dev}, B→...")
3. i_will_plan(task_id="{task_id}",
               plan="<detailed plan: scope, subtask breakdown, sequencing, risks>")
4. delegate(parent_task_id="{task_id}", title="Add login endpoint",
            description="Implement POST /login that issues a session token.",
            assigned_to="{primary_dev}", team="{team}", task_type="code",
            acceptance_criteria=["c1", "c2"], estimated_complexity="medium")
   — repeat 2 to 5 times for focused subtasks under your cell-PM task.
5. say("{channel}", "Broke down <task>: subtasks created and assigned")
6. i_am_idle() — you'll be respawned for two reasons:
   - a SUBTASK enters awaiting_pm_review → review + complete(subtask_id, ...)
   - all subtasks terminal → submit_up(task_id="{task_id}", notes=...) on YOUR task

== RULES ==

- Never `commit`, never write code, never run `git`. PMs coordinate.
- Subtasks MUST go to a developer slug in YOUR cell, not another cell's PM.
- delegate / complete / submit_up / escalate will fail unless you've logged
  a journal decision for the relevant task — read the `remediate` field.

Start now: evidence(task_id="{task_id}")
"""

    # =========================================================================
    # SMART DISPATCHER - MAIN LOOP
    # =========================================================================

    def trigger_dispatch(self) -> None:
        """Wake the dispatcher up immediately for a single pass.

        Called by API routes right after a task status transition so the
        orchestrator reacts in milliseconds — e.g. a PM creates a subtask
        and the assignee spawns within a second instead of after the next
        30-second poll. Safe to call multiple times; the Event coalesces.
        """
        self._dispatch_wake.set()

    async def _dispatcher_loop(self) -> None:
        """
        Main dispatcher loop - periodically checks for work and spawns agents.

        This is the BRAIN of the orchestrator. It:
        1. Queries for tasks needing work (pending, awaiting_qa, etc.)
        2. Queries for events needing attention (blockers, escalations)
        3. Spawns appropriate agents with task assignments

        Hybrid timing: a poll (dispatcher_interval) guarantees progress even
        without external signals, while `_dispatch_wake` lets API routes
        kick the loop for immediate reactions after status transitions.
        """
        while self._running:
            try:
                # Wait either for an explicit wake signal or the poll timeout,
                # whichever comes first. asyncio.wait_for re-raises
                # TimeoutError when the poll window expires, which we treat
                # as "run dispatch anyway".
                import contextlib

                with contextlib.suppress(TimeoutError):
                    await asyncio.wait_for(
                        self._dispatch_wake.wait(),
                        timeout=self.dispatcher_interval,
                    )
                self._dispatch_wake.clear()
                await self._dispatch_all_work()
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error("Dispatcher loop error", error=str(e))

    async def _reconcile_orphan_claims_on_startup(self) -> None:
        """Roll back tasks left in CLAIMED/IN_PROGRESS without a branch.

        A task in CLAIMED/IN_PROGRESS with ``branch_name IS NULL`` is an
        orphan: ``_finalize_claim`` flushed the status before branch creation
        failed (or before the P0-7 rollback fix landed). The next claim then
        fails non-idempotent on ``git checkout -b`` because the on-disk
        branch may exist while the DB state is stale.

        Opens its own session via the factory; the logic itself lives in
        ``_reconcile_with_service`` so tests can drive it against an
        injected session without the factory dance. Best-effort: if
        reconciliation fails, log and continue — startup must not be
        blocked by a single bad row.
        """
        from roboco.db.base import get_session_factory
        from roboco.services.task import TaskService

        factory = get_session_factory()
        try:
            async with factory() as db:
                svc = TaskService(db)
                await self._reconcile_with_service(svc)
                await db.commit()
        except Exception as exc:
            logger.error("startup reconcile failed; continuing", error=str(exc))

    async def _reconcile_with_service(self, svc: "TaskService") -> None:
        """Inner reconcile loop, parameterised by the TaskService to use.

        Same shape as ``_reap_with_service`` — extracted so tests can
        bypass ``get_session_factory`` and drive the logic directly.
        """
        from roboco.utils.converters import require_uuid

        candidates = await svc.list_in_progress_or_claimed()
        orphans = [t for t in candidates if not t.branch_name]
        if not orphans:
            logger.info("startup reconcile: no orphan claims")
            return
        for t in orphans:
            task_id = require_uuid(t.id)
            try:
                await svc.unclaim_for_reaper(task_id)
                logger.warning(
                    "startup reconcile: orphan claim rolled back",
                    task_id=str(task_id),
                    had_status=str(t.status),
                )
            except Exception as exc:
                logger.error(
                    "startup reconcile: rollback failed",
                    task_id=str(t.id),
                    error=str(exc),
                )

    async def _reap_stale_claims(self) -> None:
        """Release claimed/in_progress tasks whose holder hasn't heart-beat in TTL.

        Closes the "dead container squats task forever" failure mode that
        the schema hinted at (``last_heartbeat_at`` since migration 006) but
        no code enforced. The runtime decision (cutoff, iteration) lives
        here in the orchestrator; the actual UPDATE statements live in
        ``TaskService.unclaim_for_reaper``.

        Opens a fresh per-tick session — short-lived because the reaper
        runs on every dispatch cycle and the work is cheap (one SELECT
        plus N UPDATEs for the typically-empty stale set). Tests that
        need to inject a mock service do so by building an instance via
        ``__new__`` (bypassing this method) and calling
        ``_reap_with_service`` directly.
        """
        from roboco.db.base import get_session_factory
        from roboco.services.task import TaskService

        factory = get_session_factory()
        async with factory() as db:
            svc = TaskService(db)
            await self._reap_with_service(svc)
            await db.commit()

    async def _reap_with_service(self, svc: "TaskService") -> None:
        """Inner reap loop, parameterized by the TaskService to use.

        Wraps each ``unclaim_for_reaper`` in try/except so a single bad row
        doesn't abort the dispatch tick — the reaper must keep ticking even
        if one task's release somehow fails.
        """
        from roboco.utils.converters import require_uuid

        cutoff = datetime.now(UTC) - timedelta(seconds=self._claim_heartbeat_ttl)
        candidates = await svc.list_in_progress_or_claimed()
        for t in candidates:
            ts = t.last_heartbeat_at
            if ts is None or ts < cutoff:
                task_id = require_uuid(t.id)
                try:
                    await svc.unclaim_for_reaper(task_id)
                    logger.warning(
                        "stale claim reaped",
                        task_id=str(task_id),
                        last_heartbeat=ts.isoformat() if ts else None,
                    )
                except Exception as exc:
                    logger.error(
                        "stale-claim reap failed; continuing",
                        task_id=str(task_id),
                        error=str(exc),
                    )

    async def _dispatch_all_work(self) -> None:
        """Run all dispatchers to check for and assign work.

        Each dispatcher is isolated: if one raises (e.g., a transient API
        error), the rest still run in this tick instead of waiting for the
        next one.

        `_tick_handled_tasks` gives downstream dispatchers a way to
        skip tasks that an earlier dispatcher already acted on this
        tick. Order-dependent bugs (like the Fix-B scenario where
        `_dispatch_qa_work` claimed for QA and the next dispatcher
        re-spawned the dev on the same claimed row) are defanged by
        early dispatchers marking the task handled.

        The stale-claim reaper runs first, before any dispatcher tries to
        spawn an agent for a task whose previous holder is dead. Without
        this ordering, the spawn pass could race against a stale claim and
        skip work the reaper would have freed in the same tick.
        """
        self._tick_handled_tasks = set()

        # Free any tasks whose claim went stale before the spawn pass runs.
        # Wrapped because a reaper failure must not block dispatch — the
        # next tick will retry.
        try:
            await self._reap_stale_claims()
        except Exception as e:
            logger.error("Stale-claim reaper failed; continuing tick", error=str(e))

        # Orchestrator uses SYSTEM role for internal API calls
        # Using a well-known UUID for the orchestrator identity
        headers = {
            "X-Agent-ID": "00000000-0000-0000-0000-000000000000",
            "X-Agent-Role": "system",
        }
        dispatchers: list[tuple[str, Any]] = []
        async with httpx.AsyncClient(timeout=30.0, headers=headers) as client:
            dispatchers = [
                ("pm_work", self._dispatch_pm_work(client)),
                ("pm_closure_work", self._dispatch_pm_closure_work(client)),
                ("dev_work", self._dispatch_dev_work(client)),
                ("qa_work", self._dispatch_qa_work(client)),
                ("doc_work", self._dispatch_doc_work(client)),
                ("pm_review_work", self._dispatch_pm_review_work(client)),
                ("marketing_work", self._dispatch_marketing_work(client)),
                ("blocker_work", self._dispatch_blocker_work(client)),
                ("escalation_work", self._dispatch_escalation_work(client)),
                ("approval_work", self._dispatch_approval_work(client)),
                ("a2a_work", self._dispatch_a2a_work(client)),
                ("audit_work", self._dispatch_audit_work(client)),
                ("detect_stuck_tasks", self._detect_stuck_tasks(client)),
            ]
            for name, coro in dispatchers:
                try:
                    await coro
                except Exception as e:
                    logger.error(
                        "Dispatcher raised; continuing with next dispatcher",
                        dispatcher=name,
                        error=str(e),
                    )

    # =========================================================================
    # SMART DISPATCHER - TASK-BASED DISPATCHERS
    # =========================================================================

    _PM_AGENTS: ClassVar[frozenset[str]] = frozenset(
        {
            "main-pm",
            "be-pm",
            "fe-pm",
            "ux-pm",
        }
    )

    # Use foundation's default; keep the local name for back-compat.
    _PM_RESPAWN_MAX_UNPRODUCTIVE = _AGENT_LOOP_BUDGET.pm_respawn_max_unproductive

    async def _pm_respawn_should_gate(
        self, agent_slug: str, task: dict[str, Any]
    ) -> bool:
        """Return True when the respawn should be skipped (loop detected).

        Tracks (agent_slug, task_id) -> count of consecutive spawns where
        the task's status did not advance. When the task status changes,
        the counter resets. Once the count hits the threshold, the spawn
        is skipped and a warning logged; operators must intervene.

        Tracing-gap reset (Task 13)
        ---------------------------
        With the gateway claim-time gates installed, a rule-following PM
        will hit ``PARENT_NOT_CLAIMED`` (a ``tracing_gap`` envelope) and
        the prompt will tell it to call the prerequisite verb first.
        Each retry leaves the task status unchanged but the agent IS
        making progress through the verb chain. Counting that as a
        strike kills rule-followers.

        Solution: before incrementing on a same-status spawn, check
        ``audit_log`` for a ``gateway.rejected`` row tagged
        ``reason == "tracing_gap"`` from this (agent, task) since the
        last check. If found, reset the counter — the agent followed
        the rules, not stuck.

        Audit lookup is best-effort: any failure falls through to the
        legacy strike behavior so audit problems don't break the gate.
        """
        task_id = task.get("id")
        if not task_id:
            return False
        key = (agent_slug, task_id)
        current_status = task.get("status")
        record = self._pm_respawn_tracker.get(key)
        now = datetime.now(UTC)
        if record is None or record.get("last_status") != current_status:
            self._pm_respawn_tracker[key] = {
                "count": 1,
                "last_status": current_status,
                "last_check": now,
            }
            return False
        # Same status as last spawn — could be a stuck loop OR a
        # rule-following retry. Consult audit before counting.
        if await self._pm_made_rule_following_retry(agent_slug, task_id, record):
            record["count"] = 1
            record["last_check"] = now
            return False
        record["count"] += 1
        record["last_check"] = now
        if record["count"] > self._PM_RESPAWN_MAX_UNPRODUCTIVE:
            logger.warning(
                "PM respawn loop detected — skipping spawn",
                agent_id=agent_slug,
                task_id=task_id,
                task_status=current_status,
                spawn_attempts=record["count"],
                threshold=self._PM_RESPAWN_MAX_UNPRODUCTIVE,
                hint=(
                    "Agent repeatedly spawned without advancing task state. "
                    "Investigate prompt/schema drift or escalate manually."
                ),
            )
            return True
        return False

    async def _pm_made_rule_following_retry(
        self,
        agent_slug: str,
        task_id: str,
        record: dict[str, Any],
    ) -> bool:
        """Did the agent emit a ``tracing_gap`` envelope since the last check?

        Returns ``False`` for unknown slugs (defensive — the audit query
        needs an agent UUID, and we'd rather fall through to the legacy
        strike behavior than crash). Returns ``False`` if the audit
        lookup raises — observability must never block the gate.
        """
        agent_uuid_str = AGENT_UUIDS.get(agent_slug)
        if not agent_uuid_str:
            return False
        from uuid import UUID

        try:
            agent_uuid = UUID(agent_uuid_str)
            task_uuid = UUID(task_id)
        except (ValueError, TypeError):
            return False
        since = record.get("last_check") or datetime.now(UTC)

        from roboco.services.audit import get_audit_service

        audit = get_audit_service()
        try:
            return await audit.has_recent_tracing_gap(
                agent_id=agent_uuid,
                task_id=task_uuid,
                since=since,
            )
        except Exception as exc:
            logger.debug(
                "audit.has_recent_tracing_gap failed; falling back to strike count",
                agent_slug=agent_slug,
                task_id=task_id,
                error=str(exc),
            )
            return False

    async def _handle_pm_assigned_task(
        self, task: dict[str, Any], assigned_to: str
    ) -> None:
        """Spawn an already-assigned PM agent if it isn't running."""
        agent_slug = self._resolve_agent_slug(assigned_to)
        if agent_slug not in self._PM_AGENTS or self._is_agent_active(agent_slug):
            return
        if await self._pm_respawn_should_gate(agent_slug, task):
            return
        logger.info(
            "Spawning assigned PM agent",
            task_id=task.get("id"),
            agent_id=agent_slug,
        )
        pm_prompt = (
            self._build_main_pm_triage_prompt(task)
            if agent_slug == "main-pm"
            else self._build_pm_triage_prompt(task)
        )
        await self.spawn_agent(
            agent_id=agent_slug,
            task_id=task["id"],
            initial_prompt=pm_prompt,
            git_context=self._task_git_context(task),
        )

    def _pm_spawn_prompt(
        self, routing: str, agent_id: str, task: dict[str, Any]
    ) -> str:
        """Pick the correct prompt for a classified spawn."""
        if routing == "dev":
            return self._build_dev_prompt(task)
        if routing == "main_pm" or agent_id == "main-pm":
            return self._build_main_pm_triage_prompt(task)
        return self._build_pm_triage_prompt(task)

    async def _route_unassigned_pm_task(
        self, client: httpx.AsyncClient, task: dict[str, Any]
    ) -> None:
        """Classify and route an unassigned pending task to its target agent."""
        routing = self._classify_task_routing(task)
        agent_id = self._get_routing_target(routing, task)

        if not agent_id:
            logger.warning(
                "No routing target found",
                task_id=task.get("id"),
                routing=routing,
            )
            return

        # Don't auto-claim back to the creator. A PM that just created this
        # task is about to assign it (e.g. be-pm creating a code subtask to
        # hand to be-dev-1 one tool-call later). Racing in and claiming for
        # the PM hijacks the delegation — the PM ends up owning a code task
        # it never intended to work on itself. Skip this tick and let the
        # next dispatch pick it up once assigned_to is set, OR re-evaluate
        # when we have a clearer signal the creator won't route it.
        created_by = task.get("created_by")
        if created_by:
            creator_slug = self._resolve_agent_slug(str(created_by))
            if creator_slug == agent_id:
                logger.info(
                    "Skipping auto-claim: routing target is the creator",
                    task_id=task.get("id"),
                    creator=creator_slug,
                    routing=routing,
                )
                return

        logger.info(
            "Routing task",
            task_id=task.get("id"),
            routing=routing,
            agent_id=agent_id,
        )

        if self._is_agent_active(agent_id):
            await self._claim_task_for_agent(client, task["id"], agent_id)
            return

        if await self._claim_task_for_agent(client, task["id"], agent_id):
            prompt = self._pm_spawn_prompt(routing, agent_id, task)
            await self.spawn_agent(
                agent_id=agent_id,
                task_id=task["id"],
                initial_prompt=prompt,
                git_context=self._task_git_context(task),
            )

    async def _dispatch_pm_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch PM triage work - routes new tasks to appropriate level.

        This is the FIRST dispatcher called - it classifies unassigned tasks
        and routes them to Board, Main PM, Cell PM, or directly to devs.
        Also handles already-assigned pending tasks for PM agents.

        Monitors: pending tasks (both assigned and unassigned)
        Spawns: product-owner, main-pm, be-pm, fe-pm, ux-pm (or devs for simple)
        """
        tasks = await self._fetch_tasks(client, "pending")

        for task in tasks:
            if self._is_task_handled_this_tick(task.get("id")):
                continue
            assigned_to = task.get("assigned_to")
            if assigned_to:
                await self._handle_pm_assigned_task(task, assigned_to)
                continue

            await self._route_unassigned_pm_task(client, task)

    @staticmethod
    def _all_descendants_terminal(descendants: list[dict[str, Any]]) -> bool:
        """Every descendant in a closure-complete state?"""
        return all(st.get("status") in ("completed", "cancelled") for st in descendants)

    @staticmethod
    def _already_promoted_for_closure(task: dict[str, Any]) -> bool:
        """Skip closure respawn when PR+status show task has moved up."""
        return bool(
            task.get("pr_number")
            and task.get("status")
            in ("awaiting_pm_review", "awaiting_ceo_approval", "completed")
        )

    @staticmethod
    def _coerce_heartbeat(value: Any) -> datetime | None:
        """Normalize ``last_heartbeat_at`` to an aware UTC datetime.

        The dispatcher reads tasks via the HTTP API, which serializes
        datetimes as ISO-8601 strings; direct service callers (and tests)
        may pass ``datetime`` objects. Anything else is treated as
        absent so a malformed value can't accidentally arm the gate.
        """
        if value is None:
            return None
        if isinstance(value, datetime):
            return value if value.tzinfo else value.replace(tzinfo=UTC)
        if isinstance(value, str):
            try:
                parsed = datetime.fromisoformat(value)
            except ValueError:
                return None
            return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
        return None

    def _is_recently_paused(self, task: dict[str, Any]) -> bool:
        """A paused task whose heartbeat is fresher than the stale cutoff.

        Closes the ``i_am_idle`` vs closure-respawn race (audit C12):
        ``i_am_idle`` auto-pauses in-flight tasks and then sets the agent
        IDLE. If the dispatcher ticks between those two writes it sees a
        paused parent and would spawn the closure PM against a session
        that is mid-shutdown. A fresh ``last_heartbeat_at`` (newer than
        ``settings.claim_stale_seconds``) is the signal that the agent
        was alive moments ago and a respawn now would race the existing
        session. Genuinely-stale paused tasks (or tasks with no heartbeat
        recorded) fall through and follow the regular closure path.
        """
        if task.get("status") != "paused":
            return False
        last_hb = self._coerce_heartbeat(task.get("last_heartbeat_at"))
        if last_hb is None:
            return False
        cutoff = datetime.now(UTC) - timedelta(seconds=self._claim_heartbeat_ttl)
        return last_hb > cutoff

    def _closure_pm_for_team(self, team: str | None) -> str:
        """Pick the PM that owns closure for a given team."""
        if team in ("backend", "frontend", "ux_ui"):
            return self._TEAM_PM_MAP.get(team, "be-pm")
        return "main-pm"

    async def _maybe_spawn_pm_closure(
        self, client: httpx.AsyncClient, task: dict[str, Any]
    ) -> None:
        """If this parent task is ready for closure, spawn its PM."""
        task_id = task.get("id")
        if not task_id:
            return

        if self._is_recently_paused(task):
            logger.debug(
                "Skipping closure spawn for recently-paused parent",
                task_id=task_id,
                last_heartbeat_at=task.get("last_heartbeat_at"),
            )
            return

        descendants = await self._fetch_all_descendants(client, task_id)
        if not descendants:
            return
        if not self._all_descendants_terminal(descendants):
            return
        if self._already_promoted_for_closure(task):
            return

        pm_id = self._closure_pm_for_team(task.get("team"))
        if self._is_agent_active(pm_id):
            return

        logger.info(
            "Parent task ready for closure",
            task_id=task_id,
            descendants_count=len(descendants),
            pm_id=pm_id,
        )

        prompt = self._build_pm_closure_prompt(task, descendants)
        await self.spawn_agent(
            agent_id=pm_id,
            task_id=task_id,
            initial_prompt=prompt,
            git_context=self._task_git_context(task),
        )

    async def _dispatch_pm_closure_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch PM closure work - check parent tasks ready to close.

        When all subtasks of a parent task are completed, spawn the PM
        to review and close the parent task.

        Monitors: tasks with completed subtasks but parent still open
        Spawns: be-pm, fe-pm, ux-pm, main-pm (based on parent team)
        """
        # Find parent tasks that might have children ready for closure
        # Include "paused" - PM pauses while waiting, respawned when subtasks done
        # Include "awaiting_pm_review" - parent awaiting review when children done
        parent_statuses = ["claimed", "in_progress", "paused", "awaiting_pm_review"]

        for status in parent_statuses:
            tasks = await self._fetch_tasks(client, status)
            for task in tasks:
                await self._maybe_spawn_pm_closure(client, task)

    async def _fetch_subtasks(
        self, client: httpx.AsyncClient, parent_id: str
    ) -> list[dict[str, Any]]:
        """Fetch direct subtasks for a parent task."""
        try:
            resp = await client.get(
                f"{self._api_url}/tasks",
                params={"parent_task_id": parent_id},
            )
            if resp.status_code == http_status.HTTP_200_OK:
                data = resp.json()
                tasks = data.get("tasks", data) if isinstance(data, dict) else data
                return list(tasks) if tasks else []
        except Exception as e:
            logger.warning(
                "Failed to fetch subtasks", parent_id=parent_id, error=str(e)
            )
        return []

    async def _fetch_all_descendants(
        self, client: httpx.AsyncClient, task_id: str
    ) -> list[dict[str, Any]]:
        """Fetch ALL descendants (children, grandchildren, etc.) recursively.

        Uses the /tasks/{id}/descendants endpoint which does BFS traversal.
        """
        try:
            resp = await client.get(f"{self._api_url}/tasks/{task_id}/descendants")
            if resp.status_code == http_status.HTTP_200_OK:
                data = resp.json()
                # Endpoint returns list directly
                return list(data) if data else []
        except Exception as e:
            logger.warning("Failed to fetch descendants", task_id=task_id, error=str(e))
        return []

    def _build_pm_closure_prompt(
        self, task: dict[str, Any], subtasks: list[dict[str, Any]]
    ) -> str:
        """Prompt for PM closing their own parent task (subtasks terminal)."""
        task_id = task.get("id", "unknown")
        title = task.get("title", "Untitled")
        team = task.get("team", "unknown")

        subtask_summary = "\n".join(
            f"  - {st.get('title', 'Untitled')} ({st.get('status', 'unknown')})"
            for st in subtasks
        )

        is_root = not task.get("parent_task_id")
        project_slug = task.get("project_slug", "")

        if is_root:
            target_line = (
                "submit_up promotes to awaiting_ceo_approval; the CEO reviews "
                "and merges to master. You do NOT merge to master yourself."
            )
            submit_step = (
                f'4. submit_up(task_id="{task_id}",\n'
                '       notes="<aggregate summary: what shipped across the '
                'cells, evidence, risk callouts>")\n'
                "   — promotes to awaiting_ceo_approval. "
                "CEO is the final approver."
            )
        else:
            target_line = (
                "submit_up opens your cell-level PR into the parent task's "
                "branch and transitions you to awaiting_pm_review for the "
                "parent PM."
            )
            submit_step = (
                f'4. submit_up(task_id="{task_id}",\n'
                '       notes="<cell summary: what your cell shipped, '
                'evidence>")\n'
                "   — opens cell-level PR up to the parent's branch and "
                "transitions to awaiting_pm_review."
            )

        return f"""You are closing YOUR OWN parent task. All subtasks are
terminal — promote the merged work one level up the hierarchy.

TASK: {task_id}
TITLE: {title}
TEAM: {team}
PROJECT: {project_slug}
ROOT TASK: {"yes" if is_root else "no"}

SUBTASK SUMMARY:
{subtask_summary}

PROMOTION TARGET: {target_line}

== PM CLOSURE WORKFLOW ==

1. evidence(task_id="{task_id}")
   — review aggregate state, every acceptance criterion, and each
   subtask's terminal status. Returns the inline diff for your branch
   (all merged subtask work).

2. If any subtask is still in awaiting_pm_review, review + close it FIRST:
   - APPROVE leaf: complete(task_id="<subtask_id>",
                            notes="<merge rationale>")
     (auto-merges the leaf PR into your cell branch).
   - NEEDS REWORK: leave a clear note(scope='decision',
     task_id="<subtask_id>", text="...") and rely on the dispatcher to
     respawn the dev for revision.

3. note(scope='decision', task_id="{task_id}",
        text="Closure: {title} — <rationale, AC coverage, risks>")
   — REQUIRED before submit_up().

{submit_step}

5. i_am_idle()

Never `commit`, never write code, never run `git`. PMs coordinate.
"""

    def _get_prompt_for_agent(self, agent_slug: str, task: dict[str, Any]) -> str:
        """Get the appropriate prompt based on agent role."""
        role = get_agent_role(agent_slug)
        if role == "developer":
            return self._build_dev_prompt(task)
        elif role == "documenter":
            return self._build_doc_prompt(task)
        elif role == "qa":
            return self._build_qa_prompt(task)
        else:
            # PM or other - use dev prompt as fallback
            return self._build_dev_prompt(task)

    async def _dispatch_dev_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch assigned work to the assigned agent.

        NOTE: This handles PRE-ASSIGNED tasks (assigned by PM),
        needs_revision tasks, and in_progress tasks where agent is not active
        (e.g., after unblock). New unassigned pending tasks are handled by
        _dispatch_pm_work() which routes them through the PM hierarchy.

        Monitors: assigned pending tasks, needs_revision tasks, orphaned in_progress
        Spawns: Any assigned agent (dev, doc, qa) with appropriate prompt
        """
        # Get tasks needing attention. Includes:
        # - `claimed` — PM-delegated claims where the assignee was never spawned
        # - `blocked` — but only when another agent can resolve (see below)
        # `pending`, `needs_revision`, `in_progress` are the classic cases.
        tasks = await self._fetch_tasks(
            client,
            ["pending", "claimed", "needs_revision", "in_progress", "blocked"],
        )

        for task in tasks:
            if self._is_task_handled_this_tick(task.get("id")):
                continue
            await self._dev_dispatch_one(client, task)

    @staticmethod
    def _resolve_dev_owner_uuid(task: dict[str, Any]) -> str | None:
        """Pick the right owner UUID for dev dispatch based on status."""
        status = task.get("status")
        if status in ("claimed", "blocked"):
            return task.get("claimed_by") or task.get("assigned_to")
        return task.get("assigned_to")

    async def _respawn_dev_if_inactive(
        self, task: dict[str, Any], agent_slug: str
    ) -> None:
        """Respawn a dev agent on an existing task when it isn't running."""
        if self._is_agent_active(agent_slug):
            return
        await self.spawn_agent(
            agent_id=agent_slug,
            task_id=task["id"],
            initial_prompt=self._build_dev_prompt(task),
            git_context=self._task_git_context(task),
        )

    async def _spawn_pending_dev(
        self,
        client: httpx.AsyncClient,
        task: dict[str, Any],
        agent_slug: str,
    ) -> None:
        """Validate and spawn a dev agent for a pending, pre-assigned task."""
        if self._is_agent_active(agent_slug):
            return
        validation_issue = await self._validate_task_for_spawn(client, task, agent_slug)
        if validation_issue:
            logger.warning(
                "Skipping spawn due to validation failure",
                task_id=task["id"],
                agent=agent_slug,
                reason=validation_issue,
            )
            return
        await self.spawn_agent(
            agent_id=agent_slug,
            task_id=task["id"],
            initial_prompt=self._get_prompt_for_agent(agent_slug, task),
            git_context=self._task_git_context(task),
        )

    @staticmethod
    def _is_hitl_blocked(task: dict[str, Any]) -> bool:
        """HITL-blocked tasks wait for human resolution; skip respawn."""
        return (
            task.get("status") == "blocked"
            and task.get("blocker_resolver_type") == "human"
        )

    async def _handle_dev_existing_owner(
        self, task: dict[str, Any], status: str, agent_slug: str
    ) -> None:
        """Respawn existing dev for needs_revision / in_progress / claimed / blocked."""
        if status in (
            "in_progress",
            "claimed",
            "blocked",
        ) and not self._is_agent_active(agent_slug):
            logger.info(
                "Respawning agent for orphaned task",
                task_id=task["id"],
                agent=agent_slug,
                status=status,
            )
        await self._respawn_dev_if_inactive(task, agent_slug)

    async def _dev_dispatch_one(
        self, client: httpx.AsyncClient, task: dict[str, Any]
    ) -> None:
        """Dispatch a single task from `_dispatch_dev_work`'s fetch set."""
        team = task.get("team")
        if team not in ["backend", "frontend", "ux_ui"]:
            return

        if self._is_hitl_blocked(task):
            logger.debug(
                "Skipping HITL-blocked task; waiting for human",
                task_id=task["id"],
            )
            return

        status = task.get("status")
        owner_uuid = self._resolve_dev_owner_uuid(task)
        agent_slug = self._resolve_agent_slug(owner_uuid) if owner_uuid else None

        # Role/task_type mismatch guard (audit D-49). The dispatcher
        # previously trusted whatever ``assigned_to`` named, so a
        # documentation task accidentally assigned to a developer agent
        # would silently spawn the dev. Reject the dispatch if the
        # assignee's role doesn't match the task type — the PM that
        # mis-assigned needs to fix it before any agent runs.
        # Tasks owned by PM/board/QA roles aren't this dispatcher's lane;
        # `_dispatch_pm_work` and the QA-pool path own them. Silently skip
        # so the warning only fires on actual dev/doc misassignments.
        if agent_slug:
            assignee_role = get_agent_role(agent_slug)
            if assignee_role not in ("developer", "documenter", "unknown"):
                return
            if not self._dev_dispatch_role_matches(task, agent_slug):
                logger.warning(
                    "dev dispatch: role/task_type mismatch — skipping spawn",
                    task_id=task.get("id"),
                    task_type=task.get("task_type"),
                    assignee_slug=agent_slug,
                    assignee_role=assignee_role,
                )
                return

        if agent_slug and status in (
            "needs_revision",
            "in_progress",
            "claimed",
            "blocked",
        ):
            await self._handle_dev_existing_owner(task, status, agent_slug)
            return

        # Pending tasks pre-assigned by PM.
        if agent_slug:
            await self._spawn_pending_dev(client, task, agent_slug)

    @staticmethod
    def _dev_dispatch_role_matches(task: dict[str, Any], agent_slug: str) -> bool:
        """Return True if the assignee role matches the task's task_type.

        Dev dispatcher only spawns developer-role agents. A doc/qa task
        assigned to a dev (or vice versa) should be flagged, not silently
        spawned. Returns True when the type is unknown or the assignee role
        is unknown — the validation runs as a guard, not a strict gate, so
        an unknown classification doesn't block work that would otherwise
        proceed.
        """
        role = get_agent_role(agent_slug)
        if role == "unknown":
            return True
        task_type = task.get("task_type")
        if task_type == "documentation":
            return role == "documenter"
        # `code` / `research` / `planning` / `administrative` / `design` all
        # route through dev or PM; only the doc-task case is unambiguous.
        return role == "developer"

    async def _spawn_assigned_qa(self, task: dict[str, Any], assigned_to: str) -> bool:
        """If task.assigned_to is a QA slug, spawn/skip-if-running; else False.

        Returns True when the dispatch decision for this task was
        handled at the assignee level (spawned or already running).
        Returns False when the assigned_to is NOT a QA agent — caller
        then falls through to the unassigned-select path.
        """
        assigned_slug = self._resolve_agent_slug(assigned_to)
        if not assigned_slug or "qa" not in assigned_slug:
            logger.warning(
                "awaiting_qa task assigned to non-QA slug; reassigning via QA pool",
                task_id=task["id"],
                assigned_slug=assigned_slug,
            )
            return False
        if self._is_agent_active(assigned_slug):
            return True
        await self.spawn_agent(
            agent_id=assigned_slug,
            task_id=task["id"],
            initial_prompt=self._build_qa_prompt(task),
            git_context=self._task_git_context(task),
        )
        return True

    async def _dispatch_qa_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch QA work to QA agents.

        Monitors: awaiting_qa tasks
        Spawns: be-qa, fe-qa, ux-qa
        """
        tasks = await self._fetch_tasks(client, "awaiting_qa")

        for task in tasks:
            if self._is_task_handled_this_tick(task.get("id")):
                continue
            team = task.get("team")
            if team not in ["backend", "frontend", "ux_ui"]:
                continue

            assigned_to = task.get("assigned_to")
            if assigned_to and await self._spawn_assigned_qa(task, assigned_to):
                continue

            # Unassigned task - select QA agent for this team
            agent_id = self._select_agent_for_cell(team, "qa")
            if not agent_id:
                continue

            if self._is_agent_active(agent_id):
                # QA already running, they'll pick up on scan
                continue

            # Claim the task for QA agent BEFORE spawning
            if not await self._claim_task_for_agent(client, task["id"], agent_id):
                logger.warning(
                    "Failed to claim awaiting_qa task for QA",
                    task_id=task["id"],
                    agent_id=agent_id,
                )
                continue

            # Spawn QA agent with task assignment
            await self.spawn_agent(
                agent_id=agent_id,
                task_id=task["id"],
                initial_prompt=self._build_qa_prompt(task),
                git_context=self._task_git_context(task),
            )
            # Only spawn one QA at a time per cell
            break

    async def _dispatch_doc_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch documentation + developer work during the parallel
        awaiting_documentation phase.

        `awaiting_documentation` requires BOTH docs_complete=True AND
        pr_created=True to advance to awaiting_pm_review. Doc writes the
        docs; original developer pushes and creates the PR. Whoever
        finishes last triggers the state transition. Previously this
        dispatcher only spawned the documenter — if the documenter
        finished first, the task would sit indefinitely with pr_created=
        False and nothing would spawn the dev to finish the other half.

        Monitors: awaiting_documentation tasks
        Spawns:
            - documenter (be-doc, fe-doc, ux-doc) if docs_complete=False
            - original_developer if pr_created=False (tracked in
              quick_context as "original_developer:<uuid>")
        """
        from roboco.services.task import extract_original_developer

        # Fetch both `awaiting_documentation` and `claimed` because the
        # doc's claim transitions status from awaiting_documentation →
        # claimed. Without including `claimed` we'd miss tasks where doc
        # already grabbed it but pr_created is still false (dev hasn't
        # pushed/created PR yet). The `original_developer:` marker in
        # quick_context identifies tasks that are actually in the parallel
        # phase vs unrelated claimed tasks.
        tasks = await self._fetch_tasks(client, ["awaiting_documentation", "claimed"])
        for task in tasks:
            if self._is_task_handled_this_tick(task.get("id")):
                continue
            await self._doc_dispatch_one(client, task, extract_original_developer)

    async def _auto_assign_doc(
        self, client: httpx.AsyncClient, task: dict[str, Any], team: str
    ) -> None:
        """
        Auto-select and spawn a documenter for an unassigned awaiting_documentation task
        """
        agent_id = self._select_agent_for_cell(team, "doc")
        if not agent_id or self._is_agent_active(agent_id):
            return

        if not await self._claim_task_for_agent(client, task["id"], agent_id):
            logger.warning(
                "Failed to claim awaiting_documentation task for doc",
                task_id=task["id"],
                agent_id=agent_id,
            )
            return

        await self.spawn_agent(
            agent_id=agent_id,
            task_id=task["id"],
            initial_prompt=self._build_doc_prompt(task),
            git_context=self._task_git_context(task),
        )

    async def _doc_dispatch_one(
        self,
        client: httpx.AsyncClient,
        task: dict[str, Any],
        extract_original_developer: Any,
    ) -> None:
        """Process a single task for `_dispatch_doc_work`."""
        team = task.get("team")
        if team not in ["backend", "frontend", "ux_ui"]:
            return

        quick_context = task.get("quick_context") or ""
        dev_uuid = extract_original_developer(quick_context)
        status = task.get("status")

        # Only consider `claimed` tasks actually in the doc/PR parallel
        # phase. See `_is_parallel_phase_claim` docstring for the why.
        if status == "claimed" and not self._is_parallel_phase_claim(task, dev_uuid):
            return

        # Developer half: push + create PR
        await self._respawn_dev_for_pr_half(task, dev_uuid)

        # Documenter half: write docs
        if task.get("docs_complete"):
            return

        if await self._respawn_doc_if_assigned(task):
            return

        # Auto-assign a documenter only when still in awaiting_documentation.
        if status != "awaiting_documentation":
            return

        await self._auto_assign_doc(client, task, team)

    async def _respawn_doc_if_assigned(self, task: dict[str, Any]) -> bool:
        """If task is assigned to an inactive documenter, respawn them.

        Returns True when the task is already assigned (whether or not a
        respawn happened) so the caller can stop processing. Returns
        False when the task is unassigned so the caller can auto-select
        a documenter for it.
        """
        assigned_to = task.get("assigned_to")
        if not assigned_to:
            return False
        assigned_slug = self._resolve_agent_slug(assigned_to)
        if self._is_agent_active(assigned_slug):
            return True
        if assigned_slug and "doc" in assigned_slug:
            await self.spawn_agent(
                agent_id=assigned_slug,
                task_id=task["id"],
                initial_prompt=self._build_doc_prompt(task),
                git_context=self._task_git_context(task),
            )
        return True

    async def _dispatch_pm_review_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch PM review work to cell PMs or Main PM.

        Monitors: awaiting_pm_review tasks
        Spawns: be-pm, fe-pm, ux-pm, main-pm
        """
        tasks = await self._fetch_tasks(client, "awaiting_pm_review")

        for task in tasks:
            team = task.get("team")
            assigned_to = task.get("assigned_to")

            # If already assigned, check if that agent is running
            if assigned_to:
                assigned_slug = self._resolve_agent_slug(assigned_to)
                if self._is_agent_active(assigned_slug):
                    continue
                # Agent not running - spawn them to continue
                await self.spawn_agent(
                    agent_id=assigned_slug,
                    task_id=task["id"],
                    initial_prompt=self._build_pm_review_prompt(task),
                    git_context=self._task_git_context(task),
                )
                continue

            # Unassigned task - select PM based on team
            # Cell tasks go to Cell PM, cross-cell/main_pm tasks go to Main PM
            if team in ["backend", "frontend", "ux_ui"]:
                pm_id = self._TEAM_PM_MAP.get(team, "be-pm")
            else:
                # main_pm, board, or no team → Main PM handles it
                pm_id = "main-pm"

            if self._is_agent_active(pm_id):
                continue

            # Claim the task for PM BEFORE spawning
            if not await self._claim_task_for_agent(client, task["id"], pm_id):
                logger.warning(
                    "Failed to claim awaiting_pm_review task for PM",
                    task_id=task["id"],
                    agent_id=pm_id,
                )
                continue

            await self.spawn_agent(
                agent_id=pm_id,
                task_id=task["id"],
                initial_prompt=self._build_pm_review_prompt(task),
                git_context=self._task_git_context(task),
            )
            break

    async def _dispatch_marketing_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch marketing work to head-marketing.

        Monitors: pending tasks with team=marketing
        Spawns: head-marketing
        """
        tasks = await self._fetch_tasks(client, "pending", team="marketing")

        for task in tasks:
            # Skip already claimed/assigned tasks
            if task.get("assigned_to"):
                continue

            if self._is_agent_active("head-marketing"):
                # Already running, they'll pick up on scan
                continue

            await self.spawn_agent(
                agent_id="head-marketing",
                task_id=task["id"],
                initial_prompt=self._build_marketing_prompt(task),
                git_context=self._task_git_context(task),
            )
            break

    # =========================================================================
    # SMART DISPATCHER - EVENT-BASED DISPATCHERS
    # =========================================================================

    async def _dispatch_blocker_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch blocker resolution to Cell PMs.

        Monitors: blocked tasks
        Spawns: be-pm, fe-pm, ux-pm
        """
        tasks = await self._fetch_tasks(client, "blocked")

        for task in tasks:
            team = task.get("team")
            if team not in ["backend", "frontend", "ux_ui"]:
                continue

            agent_id = self._select_agent_for_cell(team, "pm")
            if not agent_id:
                continue

            if self._is_agent_active(agent_id):
                continue

            await self.spawn_agent(
                agent_id=agent_id,
                task_id=task["id"],
                initial_prompt=self._build_pm_blocker_prompt(task),
                git_context=self._task_git_context(task),
            )
            break

    async def _dispatch_escalation_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch escalations to appropriate managers.

        Monitors: escalation notifications (unacknowledged)
        Spawns: be-pm, fe-pm, ux-pm, main-pm, product-owner, head-marketing
        """
        notifications = await self._fetch_notifications(client, "blocker_escalation")

        for notif in notifications:
            targets = notif.get("to_agents", [])

            for agent_id in targets:
                # Resolve UUID to slug - to_agents contains UUIDs from database
                agent_slug = self._resolve_agent_slug(str(agent_id))

                valid_targets = [
                    "be-pm",
                    "fe-pm",
                    "ux-pm",
                    "main-pm",
                    "product-owner",
                    "head-marketing",
                ]
                if agent_slug not in valid_targets:
                    continue

                if self._is_agent_active(agent_slug):
                    continue

                await self.spawn_agent(
                    agent_id=agent_slug,
                    initial_prompt=self._build_escalation_prompt(notif),
                )
                break

    async def _dispatch_approval_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch approval requests to approvers.

        Monitors: approval notifications (unacknowledged)
        Spawns: product-owner, head-marketing, main-pm
        """
        notifications = await self._fetch_notifications(client, "approval")

        for notif in notifications:
            targets = notif.get("to_agents", [])

            for agent_id in targets:
                # Resolve UUID to slug - to_agents contains UUIDs from database
                agent_slug = self._resolve_agent_slug(str(agent_id))

                if agent_slug not in ["product-owner", "head-marketing", "main-pm"]:
                    continue

                if self._is_agent_active(agent_slug):
                    continue

                await self.spawn_agent(
                    agent_id=agent_slug,
                    initial_prompt=self._build_approval_prompt(notif),
                )
                break

    async def _dispatch_audit_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch audit work to the auditor.

        Monitors: quality alert notifications
        Spawns: auditor

        Note: Periodic scheduled audits can be added here in the future.
        """
        alerts = await self._fetch_notifications(client, "alert")

        for alert in alerts:
            targets = alert.get("to_agents", [])
            # Resolve UUIDs to slugs and check if auditor is a target
            target_slugs = [self._resolve_agent_slug(str(t)) for t in targets]
            if "auditor" in target_slugs and not self._is_agent_active("auditor"):
                await self.spawn_agent(
                    agent_id="auditor",
                    initial_prompt=self._build_audit_prompt(alert),
                )
                return

        # TODO: Add scheduled periodic audits
        # Check last audit time, spawn if overdue

    async def _detect_stuck_tasks(self, client: httpx.AsyncClient) -> None:
        """
        Detect and auto-block tasks that are stuck.

        This is a proactive enforcement mechanism that finds tasks which
        have been pending without progress and have prerequisite issues.
        Runs every dispatcher cycle but only takes action on truly stuck tasks.

        CEO-approved timeout: 10 minutes
        """
        STUCK_THRESHOLD_MINUTES = 10  # CEO-approved threshold

        tasks = await self._fetch_tasks(client, "pending")

        for task in tasks:
            age = self._get_task_age(task)
            if age is None or age < timedelta(minutes=STUCK_THRESHOLD_MINUTES):
                continue

            issues = self._check_stuck_conditions(task)
            issues.extend(await self._check_dev_subtask_issue(client, task))

            if issues:
                task_id = task.get("id")
                if not task_id:
                    continue
                age_mins = int(age.total_seconds() // 60)
                reason = f"Task stuck for {age_mins} minutes: " + ", ".join(issues)
                await self._auto_block_task(client, task_id, reason)
                logger.warning(
                    "Auto-blocked stuck task",
                    task_id=task_id,
                    age_minutes=age_mins,
                    issues=issues,
                )

        # Per-(role, state) SLA check. Independent from the pending-task
        # sweep above — different states, different action (escalate vs
        # auto-block).
        await self._detect_sla_exceeded(client)

    async def _check_sla_for_task(
        self,
        client: httpx.AsyncClient,
        task: dict[str, Any],
        status: str,
    ) -> None:
        """Check one task's SLA; escalate if exceeded. No-ops on missing data."""
        from roboco.enforcement.task_lifecycle import sla_seconds_for

        assigned = task.get("assigned_to")
        if not assigned:
            return
        assigned_slug = self._resolve_agent_slug(assigned)
        role = get_agent_role(assigned_slug or "")
        sla = sla_seconds_for(role, status)
        if sla is None:
            return
        age = self._time_in_state(task)
        if age is None or age.total_seconds() < sla:
            return
        task_id = task.get("id")
        if not task_id:
            return
        await self._escalate_sla_breach(
            client,
            _SlaBreach(
                task_id=str(task_id),
                role=role or "",
                status=status,
                age_seconds=int(age.total_seconds()),
                sla_seconds=sla,
            ),
        )

    async def _detect_sla_exceeded(self, client: httpx.AsyncClient) -> None:
        """Auto-escalate tasks that exceeded their per-role SLA.

        Uses ROLE_STATE_SLA_KEYS in enforcement/task_lifecycle.py. Dev tasks
        stuck in `in_progress`/`verifying`, QA tasks in `claimed`, doc tasks
        in `claimed`, and cell-PM tasks in `claimed` all get a soft bump so
        work doesn't silently rot.
        """
        from roboco.enforcement.task_lifecycle import ROLE_STATE_SLA_KEYS

        # Fetch each (role, state) combo we care about. One API call per
        # unique status so we don't fan out pointlessly.
        statuses = sorted({state for _, state in ROLE_STATE_SLA_KEYS})
        for status in statuses:
            try:
                tasks = await self._fetch_tasks(client, status)
            except Exception as e:
                logger.debug(
                    "SLA sweep fetch failed; skipping status",
                    status=status,
                    error=str(e),
                )
                continue
            for task in tasks:
                await self._check_sla_for_task(client, task, status)

    def _time_in_state(self, task: dict[str, Any]) -> timedelta | None:
        """Approximate time in current state via task.updated_at.

        Not perfect — any field update bumps `updated_at`, not just status
        changes — but it's the coarse signal we have, and it under-counts
        (biased toward "agent is working") rather than over-counts, which
        matches the soft-SLA intent.
        """
        updated_at = task.get("updated_at") or task.get("created_at")
        if not updated_at:
            return None
        try:
            if updated_at.endswith("Z"):
                updated_at = updated_at[:-1] + "+00:00"
            parsed = datetime.fromisoformat(updated_at)
            if parsed.tzinfo is None:
                parsed = parsed.replace(tzinfo=UTC)
            return datetime.now(UTC) - parsed
        except (ValueError, TypeError):
            return None

    async def _escalate_sla_breach(
        self, client: httpx.AsyncClient, breach: _SlaBreach
    ) -> None:
        """Record SLA breach in dev_notes and nudge state forward.

        We don't force a state transition here — the MCP lifecycle rules are
        still authoritative. We log, annotate the task, and notify the
        assignee's escalation target. The agent's next spawn picks up the
        updated notes and usually self-escalates.
        """
        age_mins = breach.age_seconds // 60
        sla_mins = breach.sla_seconds // 60
        note = (
            f"[SLA] role={breach.role} status={breach.status} "
            f"time_in_state={age_mins}m sla={sla_mins}m. "
            "Escalating — agent should call roboco_task_escalate "
            "or roboco_task_substitute."
        )
        try:
            await client.patch(
                f"{self._api_url}/tasks/{breach.task_id}",
                json={"dev_notes": note},
            )
            logger.warning(
                "SLA breach noted on task",
                task_id=breach.task_id,
                role=breach.role,
                status=breach.status,
                age_minutes=age_mins,
                sla_minutes=sla_mins,
            )
        except Exception as e:
            logger.debug(
                "SLA breach annotation failed",
                task_id=breach.task_id,
                error=str(e),
            )

    def _get_task_age(self, task: dict[str, Any]) -> timedelta | None:
        """Parse task created_at and return age, or None if unparseable."""
        created_at_str = task.get("created_at")
        if not created_at_str:
            return None
        try:
            if created_at_str.endswith("Z"):
                created_at_str = created_at_str[:-1] + "+00:00"
            created_at = datetime.fromisoformat(created_at_str)
            if created_at.tzinfo is None:
                created_at = created_at.replace(tzinfo=UTC)
            return datetime.now(UTC) - created_at
        except (ValueError, TypeError):
            return None

    _MIN_DESCRIPTION_LEN = 10

    def _check_stuck_conditions(self, task: dict[str, Any]) -> list[str]:
        """Check for common stuck conditions (git, description)."""
        issues: list[str] = []
        if not task.get("branch_name"):
            issues.append("Task missing branch_name")
        description = (task.get("description") or "").strip()
        if len(description) < self._MIN_DESCRIPTION_LEN:
            issues.append("Empty or inadequate description")
        return issues

    async def _check_dev_subtask_issue(
        self, client: httpx.AsyncClient, task: dict[str, Any]
    ) -> list[str]:
        """Check if complex dev task is missing subtasks."""
        from roboco.agents_config import get_agent_role

        assigned_to = task.get("assigned_to")
        if not assigned_to:
            return []

        agent_slug = self._resolve_agent_slug(assigned_to)
        if not agent_slug or get_agent_role(agent_slug) != "developer":
            return []

        complexity = task.get("estimated_complexity", "low")
        is_low_complexity = complexity not in ("medium", "high")
        if is_low_complexity or task.get("parent_task_id"):
            return []

        try:
            resp = await client.get(f"{self._api_url}/tasks/{task.get('id')}/subtasks")
            subtasks = resp.json() if resp.is_success else []
        except Exception:
            subtasks = []

        if not subtasks:
            return [f"{complexity} complexity task without subtasks"]
        return []

    async def _dispatch_a2a_work(self, client: httpx.AsyncClient) -> None:
        """
        Dispatch A2A (Agent-to-Agent) requests to target agents.

        Monitors: a2a_request notifications (unacknowledged)
        Spawns: Any agent that is the target of an A2A request

        This is a fallback mechanism - primary A2A routing happens via events.
        If the event-based spawn fails, these notifications will be picked up here.
        """
        notifications = await self._fetch_notifications(client, "a2a_request")

        for notif in notifications:
            targets = notif.get("to_agents", [])

            for agent_id in targets:
                # Resolve UUID to slug - to_agents contains UUIDs from database
                agent_slug = self._resolve_agent_slug(str(agent_id))

                if self._is_agent_active(agent_slug):
                    # Agent is online - SDK handles A2A delivery directly
                    # No action needed here, SDK server receives messages
                    continue

                # Agent is offline - spawn them with A2A context
                await self.spawn_agent(
                    agent_id=agent_slug,
                    initial_prompt=self._build_a2a_prompt(notif),
                )
                break

    # =========================================================================
    # SMART DISPATCHER - PROMPT BUILDERS
    # =========================================================================

    def _get_workflow_state(
        self,
        status: str,
        has_plan: bool,
    ) -> str:
        """Determine developer workflow state from task attributes.

        Args:
            status: Task status (claimed, in_progress, needs_revision, etc.)
            has_plan: Whether task has a plan submitted

        Returns:
            Workflow state string (NEEDS_PLAN, READY_TO_START, EXECUTING, etc.)
        """
        # Direct status mappings
        status_map = {
            "in_progress": "EXECUTING",
            "needs_revision": "REVISION_REQUIRED",
            "verifying": "VERIFYING",
        }

        if status in status_map:
            return status_map[status]

        # Handle claimed status with sub-states
        if status == "claimed":
            if not has_plan:
                return "NEEDS_PLAN"
            return "READY_TO_START"

        return status.upper()

    def _get_workflow_instructions(self, state: str, task_id: str) -> str:
        """Get workflow instructions for the given state.

        Args:
            state: Workflow state (NEEDS_PLAN, READY_TO_START, etc.)
            task_id: Task ID for tool call examples

        Returns:
            Markdown-formatted instructions for the current state
        """
        instructions = {
            "NEEDS_PLAN": f"""## NEXT STEP: Claim + Plan + Start

Call i_will_work_on(task_id="{task_id}",
    plan="<approach, ordered steps, risks, open questions>").

This single verb claims the task, records your plan, and transitions
to in_progress.
""",
            "READY_TO_START": f"""## NEXT STEP: Start Work

Call i_will_work_on(task_id="{task_id}", plan="<your plan as a string>")
to begin.
""",
            "EXECUTING": """## IN PROGRESS

Continue development. Required gates before i_am_done() will succeed
(enforced server-side — `remediate` tells you what's missing):
1. commit("<type(scope): subject, >=20 chars>")
   — makes the git commit, auto-prefixes task ID, records progress.
   Repeat per meaningful chunk.
2. note(scope='decision'|'learning'|'reflect', task_id="...", text=...)
   as you make trade-offs.

When acceptance criteria are met, call
open_pr(task_id="...") to push your branch and open the PR,
then i_am_done(task_id="...", notes="<self-verification summary>")
to submit for QA review.

If you hit something you can't unblock yourself:
i_am_blocked(task_id="...",
    reason="<blocked_external|low_context|...>").
""",
            "REVISION_REQUIRED": f"""## REVISION REQUESTED

QA or PM requested changes:
1. evidence(task_id="{task_id}") — read qa_notes / pm_notes / inline diff
2. i_will_work_on(task_id="{task_id}",
   plan="<revised plan addressing each issue>")
3. commit() the fixes, then
   i_am_done(task_id="{task_id}", notes="<what was fixed>")
""",
            "VERIFYING": f"""## SELF-VERIFICATION

Run the project's quality checks against acceptance criteria:
1. Run tests, lint, type checks in your workspace.
2. evidence(task_id="{task_id}") — sanity-check inline diff + commits.
3. If everything passes:
   i_am_done(task_id="{task_id}", notes="<verification summary>")
   — chains submit_verification + push + create_pr + submit_qa.
4. If issues found: commit() the fixes and retry.
""",
        }
        return instructions.get(
            state, f'Call evidence(task_id="{task_id}") to check status.'
        )

    def _build_dev_prompt(self, task: dict[str, Any]) -> str:
        """Build state-aware initial prompt for a developer."""
        task_id = task.get("id", "unknown")
        title = task.get("title", "Untitled")
        status = task.get("status", "unknown")

        # Determine workflow state based on task attributes
        has_plan = bool(task.get("plan"))
        workflow_state = self._get_workflow_state(status, has_plan)
        instructions = self._get_workflow_instructions(workflow_state, task_id)

        return f"""You have been assigned a development task.

TASK ID: {task_id}
TITLE: {title}
STATUS: {status}
WORKFLOW STATE: {workflow_state}

{instructions}

Start by calling evidence(task_id="{task_id}") for full details and acceptance criteria.

When out of work: i_am_idle().
"""

    def _build_qa_prompt(self, task: dict[str, Any]) -> str:
        """Build initial prompt for a QA agent."""
        task_id = task.get("id", "unknown")
        title = task.get("title", "Untitled")
        assigned_to = task.get("assigned_to", "unknown")
        team = task.get("team", "unknown")

        return f"""A task is ready for QA review.

TASK ID: {task_id}
TITLE: {title}
DEVELOPER: {assigned_to}
TEAM: {team}

== QA WORKFLOW ==

1. claim_review(task_id="{task_id}")
   — assigns the QA seat; returns inline diff + PR + commits as evidence.
   The PR is already open (dev opened it before submitting QA);
   review on GitHub if you need more context.
2. Review the implementation against EVERY acceptance criterion.
   Run/read tests; sanity-check the diff for regressions, security,
   and scope creep.
3. Decide:
   - PASS: pass(task_id="{task_id}",
            notes="<>=80 chars: what you verified, which AC, evidence>")
     — transitions awaiting_qa → awaiting_documentation.
   - FAIL: fail(task_id="{task_id}",
            issues=["concrete issue 1", "concrete issue 2", ...])
     — transitions to needs_revision; each issue must be specific and
     actionable.
4. note(scope='reflect'|'learning', task_id="{task_id}", text=...)
   for anything worth flagging.
5. give_me_work() to pick up the next QA item,
   or i_am_idle() if the queue is empty.
"""

    def _build_doc_prompt(self, task: dict[str, Any]) -> str:
        """Build initial prompt for a documenter."""
        task_id = task.get("id", "unknown")
        title = task.get("title", "Untitled")
        team = task.get("team", "unknown")

        return f"""A task is ready for documentation. The dev's PR is already open
— you're documenting alongside the QA-passed branch.

TASK ID: {task_id}
TITLE: {title}
TEAM: {team}

== DOC WORKFLOW ==

1. claim_doc_task(task_id="{task_id}")
   — assigns the doc seat and opens your workspace on the task's branch.
2. evidence(task_id="{task_id}") — read dev handoff notes, qa_notes,
   and the inline diff so the docs reflect what actually shipped.
3. Write/update docs in your workspace: README sections, API references,
   code comments, migration notes, or new docs files as the change requires.
4. commit("docs(scope): <subject, >=20 chars>") per logical doc chunk
   — auto-prefixes the task ID and stages tracked changes.
5. i_documented(task_id="{task_id}",
   notes="<>=20 chars: what you documented and where>",
   files=["docs/foo.md", "README.md", ...])
   — transitions awaiting_documentation → awaiting_pm_review.
6. give_me_work() for the next doc item,
   or i_am_idle() if the queue is empty.
"""

    def _build_pm_review_prompt(self, task: dict[str, Any]) -> str:
        """Prompt for PM reviewing a SUBTASK in awaiting_pm_review."""
        task_id = task.get("id", "unknown")
        title = task.get("title", "Untitled")
        team = task.get("team", "unknown")

        return f"""A SUBTASK in your cell is awaiting your PM review.
It has passed QA and documentation; the leaf PR is open and ready to merge.

TASK ID: {task_id}
TITLE: {title}
TEAM: {team}

== PM REVIEW WORKFLOW (leaf subtask) ==

1. evidence(task_id="{task_id}")
   — review PR, commits, inline diff, dev_notes, qa_notes, doc files.
2. Spot-check that:
   - every acceptance criterion is satisfied,
   - QA's pass notes line up with the actual diff,
   - docs reflect what shipped.
3. note(scope='decision', task_id="{task_id}",
        text="<approve rationale or rejection reason>")
   — REQUIRED before complete().
4. Decide:
   - APPROVE: complete(task_id="{task_id}", notes="<merge rationale>")
     — auto-merges the leaf PR and finalizes the subtask.
   - NEEDS REWORK: leave a clear note(scope='decision', text="...") and
     rely on the dispatcher to respawn the dev for revision.
     Use escalate_up only if the issue is truly outside your cell.
5. give_me_work() / triage() for the next item, or i_am_idle().

Never `commit`, never write code, never run `git`. PMs coordinate.
"""

    def _build_marketing_prompt(self, task: dict[str, Any]) -> str:
        """Build initial prompt for head-marketing with a marketing task."""
        task_id = task.get("id", "unknown")
        title = task.get("title", "Untitled")
        description = task.get("description", "No description")

        return f"""You have been assigned a marketing task.

TASK ID: {task_id}
TITLE: {title}
DESCRIPTION: {description}

Begin work:

1. Call roboco_task_get("{task_id}") for full details and acceptance criteria
2. Execute the marketing task (content, campaigns, research, etc.)
3. Coordinate with Product Owner or Main PM if needed
4. Call roboco_task_complete("{task_id}") when done
5. Call roboco_task_scan() to check for more marketing work
6. If no more work, call roboco_agent_idle() to shutdown gracefully
"""

    def _build_pm_blocker_prompt(self, task: dict[str, Any]) -> str:
        """Build initial prompt for a Cell PM handling a blocker."""
        task_id = task.get("id", "unknown")
        title = task.get("title", "Untitled")
        assigned_to = task.get("assigned_to", "unknown")
        blocker = task.get("blocker", {})
        reason = blocker.get("reason", "Unknown")
        what_needed = blocker.get("what_needed", "Unknown")

        return f"""A task in your cell is BLOCKED and needs your attention.

TASK ID: {task_id}
TITLE: {title}
ASSIGNED TO: {assigned_to}
BLOCKER REASON: {reason}
WHAT'S NEEDED: {what_needed}

Your job:

1. Understand the blocker by reviewing task details
2. Communicate with the blocked developer if needed
3. Resolve the blocker (coordinate resources, make decisions, escalate if needed)
4. Once resolved, the developer can call roboco_task_unblock()
5. Call roboco_task_scan() to check for other blocked tasks in your cell
6. If no more blockers, call roboco_agent_idle() to shutdown gracefully
"""

    def _build_escalation_prompt(self, notification: dict[str, Any]) -> str:
        """Build initial prompt for handling an escalation."""
        notif_id = notification.get("id", "unknown")
        from_agent = notification.get("from_agent", "unknown")
        subject = notification.get("subject", "No subject")
        priority = notification.get("priority", "normal")
        body = notification.get("body", "No details provided")

        return f"""You have received an ESCALATION that requires your attention.

FROM: {from_agent}
SUBJECT: {subject}
PRIORITY: {priority}

DETAILS:
{body}

Your job:

1. Acknowledge the notification with roboco_notify_ack("{notif_id}")
2. Assess the escalation and determine action needed
3. Communicate decisions via appropriate channels
4. If this requires further escalation, use roboco_escalate()
5. When resolved, call roboco_task_scan() for other work
6. If no more work, call roboco_agent_idle() to shutdown gracefully
"""

    def _build_approval_prompt(self, notification: dict[str, Any]) -> str:
        """Build initial prompt for handling an approval request."""
        notif_id = notification.get("id", "unknown")
        from_agent = notification.get("from_agent", "unknown")
        subject = notification.get("subject", "No subject")
        related_task_id = notification.get("related_task_id", "None")
        body = notification.get("body", "No details provided")

        return f"""You have received an APPROVAL REQUEST.

FROM: {from_agent}
SUBJECT: {subject}
RELATED TASK: {related_task_id}

REQUEST:
{body}

Your job:

1. Review the approval request carefully
2. If related to a task, call roboco_task_get() for context
3. Make your decision and communicate it
4. Acknowledge with roboco_notify_ack("{notif_id}")
5. Call roboco_task_scan() for other work
6. If no more work, call roboco_agent_idle() to shutdown gracefully
"""

    def _build_audit_prompt(self, alert: dict[str, Any] | None = None) -> str:
        """Build initial prompt for the auditor."""
        if alert:
            subject = alert.get("subject", "Quality issue detected")
            body = alert.get("body", "Review system quality metrics")

            return f"""QUALITY ALERT triggered your attention.

ALERT: {subject}
DETAILS: {body}

Your job:

1. Investigate the quality issue
2. Review relevant channels and task history (you have read access to all)
3. Compile your findings
4. Report to CEO via appropriate channel
5. Call roboco_agent_idle() when complete
"""

        return """Periodic AUDIT requested.

Your job:

1. Review recent activity across all cells
2. Check quality metrics (QA pass/fail rates, blocker frequency, etc.)
3. Identify any concerns or patterns
4. Compile audit report for CEO
5. Call roboco_agent_idle() when complete
"""

    def _build_a2a_prompt(self, notification: dict[str, Any]) -> str:
        """Build initial prompt for handling an A2A (Agent-to-Agent) request.

        Reads `priority` directly off the notification row (set by
        NotificationService.send_a2a_notification). Pre-Phase-3 this
        consumed a non-existent `metadata.urgent` and always rendered
        urgency_note=False; the column-level priority is now the source
        of truth.
        """
        notif_id = notification.get("id", "unknown")
        from_agent = notification.get("from_agent", "unknown")
        body = notification.get("body", "No message provided")
        related_task_id = notification.get("related_task_id")
        metadata = notification.get("metadata", {})
        skill = metadata.get("skill", "general")
        priority_raw = notification.get("priority", "normal")

        # URGENT gets the bold attention-grabber; HIGH gets a quieter
        # "higher priority" hint; NORMAL gets no prefix.
        if priority_raw == "urgent":
            urgency_note = "**URGENT** - This request has priority.\n\n"
        elif priority_raw == "high":
            urgency_note = "**HIGH PRIORITY** - Please handle promptly.\n\n"
        else:
            urgency_note = ""
        task_note = f"RELATED TASK: {related_task_id}\n" if related_task_id else ""

        return f"""You have received an A2A (Agent-to-Agent) REQUEST.

{urgency_note}FROM: {from_agent}
SKILL: {skill}
{task_note}
REQUEST:
{body}

Your job:

1. Acknowledge the notification with roboco_notify_ack("{notif_id}")
2. Process the request using your {skill} capabilities
3. Respond to {from_agent} using roboco_agent_request()
4. If you need task context, call roboco_task_get("{related_task_id or "task_id"}")
5. When done, call roboco_task_scan() for other work
6. If no more work, call roboco_agent_idle() to shutdown gracefully
"""
