"""
Agent orchestrator — spawns and manages async sub-agent jobs.

Otto evaluates incoming tasks and decides whether to handle them directly
or delegate to a sub-agent. When delegated, a DelegatedJob is created,
an asyncio.Task is spawned, and control immediately returns to the user.

On completion, the orchestrator fires the session's notify callback so the
user receives a Telegram message with the result — no polling required.

Architecture:
    - One Orchestrator per Otto process (singleton via get_orchestrator()).
    - Each sub-agent gets its own isolated message history.
    - Jobs share the same tool set as the main Otto agent (MVP; configurable later).
    - State is tracked in-memory via JobRegistry + append-only events.jsonl.
"""

import asyncio
import hashlib
import json
import os
import shlex
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from otto.acp import ACPTransportError, is_acp_model
from otto.acp.executor import (
    DEFAULT_ACP_EXECUTION_TIMEOUT_SECONDS,
    ACPExecutionResult,
    execute_acp_prompt,
)
from otto.acp.orchestration_tools import (
    ACPHandoffRequest,
    ACPOrchestrationBridge,
    ACPReviewRequest,
    ACPTaskCompletion,
)
from otto.auth import AuthStorage
from otto.delegation import (
    BOARD_DIR,
    DelegatedJob,
    DelegationContract,
    JobRegistry,
    JobStatus,
)
from otto.errors import OttoAuthError, OttoTransientError, classify_otto_error
from otto.log import get_logger
from otto.model_catalog import get_default_oauth_model
from otto.providers import resolve_provider
from otto.subagents import SubagentProfile, load_subagent_profiles


@dataclass
class _DeferredParams:
    """Run parameters stashed for jobs waiting on dependencies."""

    tools: list[Any]
    notify: Callable[[str], Awaitable[None]]
    auth_storage: Any | None = None
    output_format: str = "plain"


@dataclass
class _RuntimeRoute:
    """In-memory routing snapshot for a delegated job."""

    model_chain: list[str]
    session_model: str
    subagent_prompt: str | None = None
    warning: str | None = None
    warning_sent: bool = False


@dataclass
class _SupervisorState:
    """Per-job supervisor retry state."""

    supervisor_retries: int = 0
    last_retry_at: datetime | None = None
    human_notified: bool = False


@dataclass(frozen=True)
class _JobWorkspace:
    root: Path
    mode: str = "default"
    sandbox: str = "none"
    sandbox_network: bool = True


@dataclass(frozen=True)
class _ExecutionOutcome:
    text: str
    summary: str | None = None
    changed_files: tuple[str, ...] = ()
    blocker: str | None = None
    handoff: ACPHandoffRequest | None = None
    review: ACPReviewRequest | None = None


_log = get_logger("otto.orchestrator")

# Maximum concurrent sub-agent jobs per orchestrator instance.
_MAX_PARALLEL_JOBS = 10

# On upstream rate-limit (429-like) errors, retry same model this many times
# before advancing to the next model in the fallback chain.
_MAX_RATE_LIMIT_RETRIES = 3
_RATE_LIMIT_RETRY_DELAY_SECONDS = 1.0
_MODEL_SWITCH_DELAY_SECONDS = 1.0
_RECONCILE_INTERVAL_SECONDS = 5.0
_VALIDATION_TIMEOUT_SECONDS = 30
_VALIDATION_BASELINE_TTL_SECONDS = 300.0
_SUPERVISOR_INTERVAL_SECONDS = 30.0
_SUPERVISOR_BACKOFF_SECONDS = (30.0, 60.0, 120.0)
_DEFAULT_MAX_SUPERVISOR_RETRIES = 3
_BASELINE_CACHE_MISS = object()


@dataclass(frozen=True)
class _ValidationResult:
    passed: bool
    command: str = ""
    stdout: str = ""
    stderr: str = ""
    returncode: int | None = None
    warning: str = ""


@dataclass(frozen=True)
class _ValidationBaseline:
    returncode: int
    stdout_key: str


@dataclass(frozen=True)
class _ValidationBaselineCacheEntry:
    baseline: _ValidationBaseline | None
    captured_at: float


def _validation_stdout_key(stdout: str) -> str:
    if not stdout:
        return ""
    return hashlib.sha256(stdout.encode("utf-8")).hexdigest()


def _normalize_model_id(value: str | None) -> str | None:
    if not isinstance(value, str):
        return None
    normalized = value.strip()
    return normalized or None


def _dedupe_models(candidates: list[str | None]) -> list[str]:
    deduped: list[str] = []
    seen: set[str] = set()
    for candidate in candidates:
        model = _normalize_model_id(candidate)
        if model is None or model in seen:
            continue
        seen.add(model)
        deduped.append(model)
    return deduped


def _format_duration(seconds: float | None) -> str:
    """Human-readable duration string."""
    if seconds is None:
        return ""
    if seconds < 60:
        return f"{seconds:.0f}s"
    minutes = seconds / 60
    if minutes < 60:
        return f"{minutes:.1f}m"
    hours = minutes / 60
    return f"{hours:.1f}h"


def _summarize_result_text(text: str, *, max_chars: int = 1200) -> str:
    """Clean and summarize noisy sub-agent output for user notifications."""
    raw_lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
    if not raw_lines:
        return "(sub-agent produced no text output)"

    noise_prefixes = (
        "ok",
        "proceed",
        "let's",
        "tool:",
        "tool call",
        "call edit_file",
        "stop chatter",
        "no filler",
        "i will call",
        "i'm failing to",
        "i will do tool",
    )

    cleaned: list[str] = []
    for line in raw_lines:
        lower = line.lower()
        if lower.startswith('{"command":'):
            continue
        if any(lower.startswith(prefix) for prefix in noise_prefixes):
            continue
        if cleaned and cleaned[-1] == line:
            continue
        cleaned.append(line)

    selected = cleaned if cleaned else raw_lines[:3]
    summary = "\n".join(selected[:12])
    if len(summary) > max_chars:
        summary = summary[:max_chars] + "\n... (truncated)"
    return summary


def _resolve_job_workspace(job: DelegatedJob) -> _JobWorkspace | None:
    try:
        from otto.config import load_config

        cfg = load_config()
    except Exception as exc:
        _log.warning("Unable to load config for workspace resolution (job %s): %s", job.id, exc)
        return None

    bots = list(getattr(cfg, "bots", []) or [])
    bot = None
    if job.bot_id and bots:
        bot = next(
            (configured_bot for configured_bot in bots if configured_bot.name == job.bot_id),
            None,
        )
    chat_id = job.chat_id.strip() if isinstance(job.chat_id, str) else ""
    bot_name_candidates = [chat_id] if chat_id else []
    if ":" in chat_id:
        bot_name_candidates.append(chat_id.split(":", 1)[0].strip())

    if bot is None:
        for candidate in bot_name_candidates:
            for configured_bot in bots:
                if configured_bot.name == candidate:
                    bot = configured_bot
                    break
            if bot is not None:
                break

    if bot is None and bots:
        bot = bots[0]

    if bot is not None:
        workspace = getattr(bot, "workspace", None)
        workspace_root = getattr(workspace, "root", None)
        if workspace_root is not None:
            return _JobWorkspace(
                root=Path(workspace_root),
                mode=str(getattr(workspace, "mode", "default") or "default"),
                sandbox=str(getattr(workspace, "sandbox", "none") or "none"),
                sandbox_network=bool(getattr(workspace, "sandbox_network", True)),
            )

    workspace = getattr(cfg, "workspace", None)
    workspace_root = getattr(workspace, "root", None)
    if workspace_root is not None:
        return _JobWorkspace(
            root=Path(workspace_root),
            mode=str(getattr(workspace, "mode", "default") or "default"),
            sandbox=str(getattr(workspace, "sandbox", "none") or "none"),
            sandbox_network=bool(getattr(workspace, "sandbox_network", True)),
        )

    fallback_root = getattr(cfg, "workdir", None)
    if fallback_root is None:
        return None
    return _JobWorkspace(root=Path(fallback_root))


def _resolve_validation_cwd(job: DelegatedJob) -> str | None:
    """Resolve validation cwd from the delegated job's bot workspace config."""
    workspace = _resolve_job_workspace(job)
    if workspace is None:
        return None
    return str(workspace.root)


def _normalize_scope_target(root: Path, raw_path: str, *, strict: bool) -> Path | None:
    candidate = Path(raw_path)
    if not candidate.is_absolute():
        candidate = root / candidate
    scope = candidate if candidate.exists() and candidate.is_dir() else candidate.parent
    resolved = scope.resolve(strict=False)
    if not strict:
        return resolved
    try:
        resolved.relative_to(root.resolve(strict=False))
    except ValueError:
        return None
    return resolved


def _resolve_task_scoped_cwd(job: DelegatedJob, *, root: Path, strict: bool) -> Path:
    scope_targets = [*job.contract.context_files, *job.contract.deliverables]
    resolved_targets = [
        path
        for raw in scope_targets
        if (path := _normalize_scope_target(root, raw, strict=strict)) is not None
    ]
    if not resolved_targets:
        return root.resolve(strict=False)
    common = Path(os.path.commonpath([str(path) for path in resolved_targets]))
    if strict:
        try:
            common.relative_to(root.resolve(strict=False))
        except ValueError:
            return root.resolve(strict=False)
    return common


def _build_followup_context(
    job: DelegatedJob,
    outcome: _ExecutionOutcome,
    extra_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
    context = dict(extra_context or {})
    context.setdefault("source_job_id", job.id)
    context.setdefault("source_agent", job.subagent_name or "main")
    context.setdefault("task", context.get("task"))
    context["upstream_task"] = job.contract.task
    context["outcome_summary"] = outcome.summary or _summarize_result_text(outcome.text)
    source_files = outcome.changed_files or tuple(job.contract.context_files)
    if source_files and "changed_files" not in context:
        context["changed_files"] = list(source_files)
    return context


def _format_handoff_task(
    job: DelegatedJob,
    outcome: _ExecutionOutcome,
    *,
    target_agent: str,
    extra_context: dict[str, Any] | None = None,
    review_files: tuple[str, ...] = (),
) -> str:
    context = _build_followup_context(job, outcome, extra_context)
    requested_task = context.pop("task", None)
    requested_task_text = requested_task if isinstance(requested_task, str) else None
    summary = context.get("outcome_summary") or _summarize_result_text(outcome.text)
    changed_files = list(context.get("changed_files", []))

    if requested_task_text and requested_task_text.strip():
        title = requested_task_text.strip()
    elif review_files:
        title = "Review the deliverables from the previous stage and decide whether they pass."
    else:
        title = f"Continue the delegated pipeline using the handoff context from {job.id}."

    lines = [title, "", "## Handoff Context"]
    lines.append(f"- Source job: {job.id}")
    lines.append(f"- Source agent: {job.subagent_name or 'main'}")
    lines.append(f"- Target agent: {target_agent}")
    lines.append(f"- Outcome summary: {summary}")
    if changed_files:
        lines.append("- Changed files:")
        lines.extend(f"  - {path}" for path in changed_files)
    if review_files:
        lines.append("- Review targets:")
        lines.extend(f"  - {path}" for path in review_files)

    visible_context = {
        key: value
        for key, value in context.items()
        if key not in {"outcome_summary", "changed_files"} and value not in (None, "", [], {})
    }
    if visible_context:
        lines.extend(
            [
                "",
                "## Additional Context",
                "```json",
                json.dumps(visible_context, indent=2, sort_keys=True),
                "```",
            ]
        )
    return "\n".join(lines)


def _coerce_execution_outcome(
    result: str | ACPExecutionResult | _ExecutionOutcome,
) -> _ExecutionOutcome:
    if isinstance(result, _ExecutionOutcome):
        return result
    if isinstance(result, str):
        return _ExecutionOutcome(text=result)

    completion: ACPTaskCompletion | None = None
    handoff = None
    review = None
    blocker = None
    if result.signals is not None:
        completion = result.signals.completion
        handoff = result.signals.handoff
        review = result.signals.review
        blocker = result.signals.blocker

    final_text = result.text
    if completion is not None and completion.result.strip():
        final_text = completion.result
    if not final_text.strip():
        final_text = "(sub-agent produced no text output)"

    return _ExecutionOutcome(
        text=final_text,
        summary=completion.outcome_summary if completion is not None else None,
        changed_files=completion.changed_files if completion is not None else (),
        blocker=blocker,
        handoff=handoff,
        review=review,
    )


# ---------------------------------------------------------------------------
# Result polish — LLM rewrite of mechanical notifications
# ---------------------------------------------------------------------------

_POLISH_TIMEOUT_SECONDS = 15
_POLISH_MAX_TOKENS = 1024

_POLISH_SYSTEM_PROMPT = """\
You are writing a notification for a user who delegated a background task. \
Write a natural response as if you just finished the work yourself. \
Go straight to what was done and what they should know — don't re-explain \
what was asked. Don't mention job IDs, task IDs, attempt counts, durations, \
or any internal mechanics. Keep it concise but complete.\
"""

_POLISH_SYSTEM_PROMPT_FANIN = """\
You are writing a notification for a user who delegated a background task \
that involved multiple parallel sub-tasks. Summarize the combined outcome \
naturally. Don't list sub-task IDs or mention the parallel/fan-in structure. \
Go straight to results — don't re-explain what was asked. Don't mention job IDs, \
task IDs, attempt counts, durations, or any internal mechanics. \
Keep it concise but complete.\
"""


def _build_polish_prompt(job: DelegatedJob, output_format: str) -> str:
    """Build the user-message prompt for the polish LLM call."""
    parts: list[str] = []
    parts.append(f"Original task:\n{job.contract.task}")
    if job.result_summary:
        parts.append(f"\nResult from agent:\n{job.result_summary}")
    if job.log_entries:
        timeline = "\n".join(f"- {e}" for e in job.log_entries[-8:])
        parts.append(f"\nTimeline:\n{timeline}")
    fmt_note = {
        "telegram_html": "Format using Telegram HTML tags (<b>, <i>, <code>, <pre>). No Markdown.",
        "markdown": "Format using Markdown.",
        "plain": "Plain text, no formatting markup.",
    }.get(output_format, "Plain text, no formatting markup.")
    parts.append(f"\nOutput format: {fmt_note}")
    return "\n".join(parts)


async def _polish_result(
    job: DelegatedJob,
    *,
    model: str,
    output_format: str = "plain",
    auth_storage: AuthStorage | None = None,
) -> str | None:
    """Run a lightweight LLM call to rewrite a job result as a natural message.

    Returns the polished text, or None if the call fails or times out.
    """
    system = _POLISH_SYSTEM_PROMPT_FANIN if job.children else _POLISH_SYSTEM_PROMPT
    user_prompt = _build_polish_prompt(job, output_format)
    messages = [{"role": "user", "content": user_prompt}]

    try:
        provider = await resolve_provider(model, system, auth_storage=auth_storage)
        stream = await provider.stream_completion(
            model=model,
            messages=[{"role": "system", "content": system}, *messages],
            tools=[],
            tool_choice=None,
            max_tokens=_POLISH_MAX_TOKENS,
        )
        text_parts: list[str] = []
        polished = await asyncio.wait_for(
            _consume_polish_stream(stream, text_parts),
            timeout=_POLISH_TIMEOUT_SECONDS,
        )
    except Exception as exc:
        mapped_exc = classify_otto_error(exc)
        _log.warning(
            "Polish call failed for job %s on %s (%s): %s",
            job.id,
            model,
            type(mapped_exc).__name__,
            mapped_exc,
        )
        return None

    if not polished:
        _log.warning("Polish call returned empty text for job %s on %s", job.id, model)
        return None
    return polished


async def _consume_polish_stream(stream: Any, text_parts: list[str]) -> str | None:
    """Consume a streaming LLM response, return assembled text or None."""
    async for chunk in stream:
        choices = getattr(chunk, "choices", None)
        if not choices:
            continue
        delta = getattr(choices[0], "delta", None)
        if delta is None:
            continue
        content = getattr(delta, "content", None)
        if content:
            text_parts.append(content)
    result = "".join(text_parts).strip()
    return result if result else None


def _format_notification(
    job: DelegatedJob,
    *,
    full_result: str | None = None,
    output_format: str = "plain",
) -> str:
    """Build a notification message for the given job state and output format.

    Supports telegram_html, markdown, and plain formats.
    """
    task_preview = job.contract.task[:80]
    use_html = output_format == "telegram_html"
    use_md = output_format == "markdown"

    # Format job ID
    if use_html:
        job_ref = f"<code>{job.id}</code>"
        task_ref = f"<i>{task_preview}</i>"
    elif use_md:
        job_ref = f"`{job.id}`"
        task_ref = f"*{task_preview}*"
    else:
        job_ref = job.id
        task_ref = task_preview

    # Metrics line (duration + attempts)
    metrics_parts: list[str] = []
    if job.duration is not None:
        metrics_parts.append(f"Duration: {_format_duration(job.duration)}")
    if job.attempts > 1:
        metrics_parts.append(f"Attempts: {job.attempts}")
    metrics_line = " | ".join(metrics_parts)

    if job.status == JobStatus.DONE:
        body = full_result or job.result_summary or "(no summary)"
        if len(body) > 4000:
            body = body[:4000] + "\n... (truncated)"
        parts = [f"✅ Job {job_ref} complete.", task_ref]
        if metrics_line:
            parts.append(metrics_line)
        parts.append("")
        parts.append(body)
        return "\n".join(parts)

    if job.status == JobStatus.CANCELLED:
        return f"🚫 Job {job_ref} was cancelled.\n{task_ref}"

    if job.status == JobStatus.BLOCKED:
        details = full_result or job.error or "Needs human input."
        parts = [f"⚠️ Job {job_ref} is blocked.", task_ref]
        if metrics_line:
            parts.append(metrics_line)
        parts.append(details[:4000])
        return "\n".join(parts)

    # FAILED
    error = job.error or "Unknown error"
    parts = [
        f"❌ Job {job_ref} failed after {job.attempts} attempt(s).",
        task_ref,
    ]
    if metrics_line:
        parts.append(metrics_line)
    if use_html:
        parts.append(f"Error: <code>{error[:200]}</code>")
    elif use_md:
        parts.append(f"Error: `{error[:200]}`")
    else:
        parts.append(f"Error: {error[:200]}")
    return "\n".join(parts)


class Orchestrator:
    """
    Manages the full lifecycle of delegated sub-agent jobs.

    Usage:
        orchestrator = get_orchestrator()
        job_id = await orchestrator.delegate(contract, chat_id, notify=notify_fn, tools=tools)
        # user keeps chatting; notify fires when the job is done
    """

    def __init__(
        self,
        max_parallel: int = _MAX_PARALLEL_JOBS,
        default_timeout: int = 1800,
        max_supervisor_retries: int = _DEFAULT_MAX_SUPERVISOR_RETRIES,
    ) -> None:
        self._registry = JobRegistry()
        self._tasks: dict[str, asyncio.Task[None]] = {}
        self._deferred: dict[str, _DeferredParams] = {}
        self._run_params: dict[str, _DeferredParams] = {}
        self._runtime_routes: dict[str, _RuntimeRoute] = {}
        self._supervisor_state: dict[str, _SupervisorState] = {}
        self._dep_retried: dict[str, set[str]] = {}
        self._validation_baseline_cache: dict[
            tuple[str | None, tuple[str, ...]],
            _ValidationBaselineCacheEntry,
        ] = {}
        self._validation_baseline_locks: dict[
            tuple[str | None, tuple[str, ...]],
            asyncio.Lock,
        ] = {}
        self._job_validation_baselines: dict[str, _ValidationBaseline] = {}
        self._needs_reconstruction: set[str] = set()
        self._supervisor_task: asyncio.Task[None] | None = None
        # Job IDs removed externally (brainfile file moved/deleted) while still running.
        # Used to prevent background tasks from resurrecting jobs in memory/disk.
        self._tombstones: set[str] = set()
        self._last_reconcile_ts = 0.0
        self._lock = asyncio.Lock()
        self._max_parallel = max_parallel
        self._default_timeout = default_timeout
        self._max_supervisor_retries = max(0, int(max_supervisor_retries))
        self._supervisor_interval_seconds = _SUPERVISOR_INTERVAL_SECONDS
        self._default_session_model = self._load_default_session_model()
        # Startup state restore: reload persisted jobs from board/ on every start.
        recovered = self._registry.load_from_board()
        if recovered:
            _log.info("Startup: restored %d jobs from board", recovered)
        # Mark deferred jobs that need lazy reconstruction on first launch.
        # Heavy tool/memory setup is deferred to _check_pending_deps to avoid
        # slow startups with many deferred jobs.
        for job in self._registry.pending_with_deps():
            if job.id in self._deferred:
                continue
            self._needs_reconstruction.add(job.id)
            _log.info("Startup: job %s marked for lazy reconstruction", job.id)
        self._cleanup_terminal_jobs()

    def _reconstruct_deferred_params(self, job: DelegatedJob) -> _DeferredParams:
        """Rebuild deferred launch params from persisted job metadata + config."""
        from otto.config import OTTO_HOME, load_config
        from otto.memory import Memory
        from otto.tools import create_session_tools, create_tools

        config = load_config()
        bots = list(getattr(config, "bots", []) or [])

        bot = None
        if job.bot_id and bots:
            bot = next((candidate for candidate in bots if candidate.name == job.bot_id), None)
            if bot is None:
                raise RuntimeError(
                    f"Configured bot '{job.bot_id}' was not found; job can no longer be resumed."
                )
        elif bots:
            bot = bots[0]

        if bot is not None:
            workspace = bot.workspace
            workspace_mode = workspace.mode
            workspace_root = str(workspace.root)
            workspace_sandbox = getattr(workspace, "sandbox", "none")
            workspace_sandbox_network = getattr(workspace, "sandbox_network", True)
            bot_memory_path = OTTO_HOME / "bots" / bot.name / "data" / "memory.db"
            memory = Memory(bot_memory_path)
            local_skills_dir = Path(workspace_root) / "skills"
            local_skills_dir.mkdir(parents=True, exist_ok=True)
            skills_dirs: list[Path] = [local_skills_dir]
            include_shared = getattr(getattr(bot, "skills", None), "include_shared", True)
            allow_global_in_sandbox = getattr(
                getattr(bot, "skills", None),
                "allow_global_skills_in_sandbox",
                False,
            )
            sandboxed = workspace_sandbox != "none"
            if include_shared and (not sandboxed or allow_global_in_sandbox):
                skills_dirs.append(OTTO_HOME / "skills")
            default_model = bot.model or config.agent.model
            resolved_bot_id = bot.name
        else:
            workspace = getattr(config, "workspace", None)
            workspace_root = str(
                getattr(
                    workspace,
                    "root",
                    getattr(config, "workdir", Path.cwd()),
                )
            )
            workspace_mode = getattr(workspace, "mode", "default")
            workspace_sandbox = getattr(workspace, "sandbox", "none")
            workspace_sandbox_network = getattr(workspace, "sandbox_network", True)
            memory = Memory(OTTO_HOME / "data" / "memory.db")
            skills_dirs = [OTTO_HOME / "skills"]
            default_model = config.agent.model
            resolved_bot_id = None

        auth_storage = AuthStorage()

        async def _notify_recovered(message: str) -> None:
            _log.info(
                "Recovered deferred notification (job=%s bot=%s chat=%s): %s",
                job.id,
                resolved_bot_id or "default",
                job.chat_id,
                message[:500],
            )

        base_tools = create_tools(
            memory=memory,
            workspace_mode=workspace_mode,
            workspace_root=workspace_root,
            workspace_sandbox=workspace_sandbox,
            workspace_sandbox_network=workspace_sandbox_network,
            skills_dirs=skills_dirs,
        )

        session_model = job.model or (job.model_chain[0] if job.model_chain else default_model)
        session_tools = create_session_tools(
            chat_id=job.chat_id,
            pulse_runner=None,
            notify=None,
            delegate_notify=_notify_recovered,
            send_file=None,
            workspace_mode=workspace_mode,
            workspace_root=workspace_root,
            workspace_sandbox=workspace_sandbox,
            workspace_sandbox_network=workspace_sandbox_network,
            base_tools=base_tools,
            session_model=session_model,
            auth_storage=auth_storage,
            output_format=job.output_format,
        )

        return _DeferredParams(
            tools=base_tools + session_tools,
            notify=_notify_recovered,
            auth_storage=auth_storage,
            output_format=job.output_format,
        )

    @staticmethod
    def _load_default_session_model() -> str:
        try:
            from otto.config import load_config

            return load_config().agent.model
        except Exception:
            return get_default_oauth_model("openai") or "openai/gpt-5"

    def _snapshot_runtime_route(
        self,
        job: DelegatedJob,
        *,
        session_model: str | None = None,
        force: bool = False,
        explicit_model_chain: list[str] | None = None,
    ) -> _RuntimeRoute:
        if not force:
            existing = self._runtime_routes.get(job.id)
            if existing is not None:
                return existing

        previous = self._runtime_routes.get(job.id)
        resolved_session_model = _normalize_model_id(session_model)
        if resolved_session_model is None and previous is not None:
            resolved_session_model = previous.session_model
        if resolved_session_model is None:
            resolved_session_model = _normalize_model_id(self._default_session_model)
        if resolved_session_model is None:
            resolved_session_model = (
                _normalize_model_id(job.model)
                or get_default_oauth_model("openai")
                or "openai/gpt-5"
            )

        prompt: str | None = None
        warning: str | None = None

        if explicit_model_chain:
            model_chain = _dedupe_models(explicit_model_chain)
        elif job.subagent_name:
            profiles = {profile.name: profile for profile in load_subagent_profiles()}
            profile = profiles.get(job.subagent_name)
            if profile is None:
                model_chain = _dedupe_models(
                    [resolved_session_model, job.model, job.contract.model]
                )
                warning = (
                    f"Subagent profile '{job.subagent_name}' was not found at run time; "
                    "used main session routing instead."
                )
                _log.warning("job %s missing subagent profile '%s'", job.id, job.subagent_name)
            else:
                model_chain = _dedupe_models(
                    [
                        profile.model,
                        profile.fallback,
                        resolved_session_model,
                        job.model,
                        job.contract.model,
                    ]
                )
                prompt = (
                    profile.prompt.strip()
                    if isinstance(profile.prompt, str) and profile.prompt.strip()
                    else None
                )
        else:
            model_chain = _dedupe_models([job.model, resolved_session_model, job.contract.model])

        if not model_chain:
            raise RuntimeError(f"No runnable model route for delegated job {job.id}")

        job.model_chain = list(model_chain)
        job.model_index = 0
        job.model = model_chain[0]

        route = _RuntimeRoute(
            model_chain=list(model_chain),
            session_model=resolved_session_model,
            subagent_prompt=prompt,
            warning=warning,
            warning_sent=previous.warning_sent if previous else False,
        )
        self._runtime_routes[job.id] = route
        return route

    @staticmethod
    def _get_subagent_profile(name: str | None) -> SubagentProfile | None:
        if not isinstance(name, str) or not name.strip():
            return None
        profiles = {profile.name: profile for profile in load_subagent_profiles()}
        return profiles.get(name.strip())

    @staticmethod
    def _select_review_subagent() -> str:
        profiles = load_subagent_profiles()
        exact_names = ("reviewer", "qa", "review", "auditor")
        profile_map = {profile.name: profile for profile in profiles}
        for candidate in exact_names:
            if candidate in profile_map:
                return candidate
        for profile in profiles:
            haystack = f"{profile.name} {profile.description}".lower()
            if any(token in haystack for token in ("review", "qa", "test", "audit")):
                return profile.name
        return "reviewer"

    def _resolve_acp_cwd(self, job: DelegatedJob) -> str:
        workspace = _resolve_job_workspace(job)
        profile = self._get_subagent_profile(job.subagent_name)
        if workspace is None:
            return _resolve_validation_cwd(job) or str(Path.cwd())

        root = workspace.root.resolve(strict=False)
        scope = getattr(profile, "workspace_scope", "shared") if profile is not None else "shared"
        if scope == "task":
            return str(
                _resolve_task_scoped_cwd(
                    job,
                    root=root,
                    strict=workspace.mode == "strict",
                )
            )
        return str(root)

    def _resolve_acp_tool_permissions(self, job: DelegatedJob) -> list[str] | None:
        profile = self._get_subagent_profile(job.subagent_name)
        if profile is None:
            return None
        return list(profile.tool_permissions) if profile.tool_permissions is not None else None

    async def _schedule_followup_job(
        self,
        job: DelegatedJob,
        outcome: _ExecutionOutcome,
        *,
        notify: Callable[[str], Awaitable[None]],
        tools: list[Any],
        auth_storage: AuthStorage | None = None,
        output_format: str = "plain",
    ) -> bool:
        target_agent: str | None = None
        extra_context: dict[str, Any] | None = None
        review_files: tuple[str, ...] = ()

        if outcome.review is not None:
            target_agent = self._select_review_subagent()
            extra_context = {"review_summary": outcome.review.summary}
            review_files = outcome.review.files
        elif outcome.handoff is not None:
            target_agent = outcome.handoff.agent_name
            extra_context = outcome.handoff.context
            review_files = ()
        else:
            return False

        task_text = _format_handoff_task(
            job,
            outcome,
            target_agent=target_agent,
            extra_context=extra_context,
            review_files=review_files,
        )

        context_files: list[str] = list(review_files)
        if not context_files:
            context_files = list(outcome.changed_files)

        contract = DelegationContract(
            task=task_text,
            context_files=context_files,
        )
        if isinstance(extra_context, dict):

            def _list_field(name: str) -> list[str]:
                values = extra_context.get(name)
                if not isinstance(values, list):
                    return []
                return [
                    value.strip() for value in values if isinstance(value, str) and value.strip()
                ]

            explicit_context_files = extra_context.get("context_files")
            if isinstance(explicit_context_files, list):
                contract.context_files = _list_field("context_files")
            contract.deliverables = _list_field("deliverables")
            contract.constraints = _list_field("constraints")
            contract.validation_cmds = _list_field("validation_cmds")
            timeout = extra_context.get("timeout")
            if isinstance(timeout, int) and timeout > 0:
                contract.timeout = timeout
        await self.delegate(
            contract,
            job.chat_id,
            notify=notify,
            tools=tools,
            subagent_name=target_agent,
            session_model=(
                self._runtime_routes.get(job.id).session_model
                if job.id in self._runtime_routes
                else None
            ),
            bot_id=job.bot_id,
            auth_storage=auth_storage,
            output_format=output_format,
            depends_on=[job.id],
        )
        return True

    def _evict_side_tables(self, job_id: str) -> None:
        self._deferred.pop(job_id, None)
        self._run_params.pop(job_id, None)
        self._runtime_routes.pop(job_id, None)
        self._supervisor_state.pop(job_id, None)
        self._dep_retried.pop(job_id, None)
        self._job_validation_baselines.pop(job_id, None)
        self._needs_reconstruction.discard(job_id)

    def _cleanup_terminal_jobs(self) -> None:
        removed_ids = self._registry.cleanup_terminal()
        for job_id in removed_ids:
            self._evict_side_tables(job_id)

    def _maybe_start_supervisor(self) -> None:
        """Start the background supervisor loop when an event loop is available."""
        current = self._supervisor_task
        if current is not None and not current.done():
            return
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            return
        self._supervisor_task = loop.create_task(
            self._supervisor_loop(),
            name="otto-supervisor",
        )

    async def start(self) -> None:
        """Start background supervisor task (idempotent)."""
        self._maybe_start_supervisor()

    async def shutdown(self) -> None:
        """Cancel background supervisor task for clean shutdown."""
        task = self._supervisor_task
        if task is None:
            return
        if task.done():
            self._supervisor_task = None
            return
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass
        finally:
            if self._supervisor_task is task:
                self._supervisor_task = None

    def _supervisor_state_for(self, job_id: str) -> _SupervisorState:
        return self._supervisor_state.setdefault(job_id, _SupervisorState())

    def _reset_supervisor_state(self, job_id: str) -> None:
        self._supervisor_state.pop(job_id, None)

    def _job_timeout_seconds(self, job: DelegatedJob) -> int:
        timeout = job.contract.timeout
        if isinstance(timeout, int) and timeout > 0:
            return timeout
        return self._default_timeout

    @staticmethod
    def _acp_timeout_seconds(job: DelegatedJob) -> int:
        timeout = job.contract.timeout
        if isinstance(timeout, int) and timeout > 0:
            return timeout
        return DEFAULT_ACP_EXECUTION_TIMEOUT_SECONDS

    def _is_stale_in_progress_job(self, job: DelegatedJob, *, now: datetime) -> bool:
        if job.status != JobStatus.IN_PROGRESS:
            return False
        stale_after = float(self._job_timeout_seconds(job) * 2)
        elapsed = (now - job.updated_at).total_seconds()
        return elapsed > stale_after

    def _supervisor_backoff_seconds(self, retries_done: int) -> float:
        if retries_done < 0:
            retries_done = 0
        index = min(retries_done, len(_SUPERVISOR_BACKOFF_SECONDS) - 1)
        return float(_SUPERVISOR_BACKOFF_SECONDS[index])

    def _has_supervisor_targets(self) -> bool:
        """Return True when at least one job needs periodic supervision."""
        for job in self._registry.all_jobs():
            if job.status in (JobStatus.IN_PROGRESS, JobStatus.FAILED):
                return True
            if job.children and job.status not in (
                JobStatus.DONE,
                JobStatus.CANCELLED,
                JobStatus.FAILED,
            ):
                return True
        return False

    def _supervisor_human_diagnosis(self, job: DelegatedJob, state: _SupervisorState) -> str:
        model = job.model or "unknown model"
        error = (job.error or "Unknown error").strip()[:300]
        return (
            f"⚠️ Job {job.id} needs manual intervention.\n"
            f"Supervisor exhausted automatic recovery ({state.supervisor_retries}/"
            f"{self._max_supervisor_retries} retries).\n"
            f"Current model: {model}\n"
            f"Last error: {error}\n"
            "Next step: run retry_job or redirect_job with additional guidance."
        )

    async def _supervisor_loop(self) -> None:
        """Background loop that periodically repairs failed/stale delegated jobs."""
        try:
            while True:
                async with self._lock:
                    if not self._has_supervisor_targets():
                        return
                await asyncio.sleep(self._supervisor_interval_seconds)
                await self._supervisor_tick()
        except asyncio.CancelledError:
            _log.debug("Supervisor loop cancelled")
            raise
        finally:
            current = asyncio.current_task()
            if self._supervisor_task is current:
                self._supervisor_task = None

    async def _supervisor_tick(self) -> None:
        """Single supervisor pass: stale detection + failed auto-recovery."""
        notifications: list[tuple[Callable[[str], Awaitable[None]], str]] = []

        async with self._lock:
            self._reconcile_stale_jobs()
            now = datetime.now(UTC)

            # 1) Mark stale in-flight jobs as FAILED so they can enter retry flow.
            for job in list(self._registry.by_status(JobStatus.IN_PROGRESS)):
                if not self._is_stale_in_progress_job(job, now=now):
                    continue

                elapsed = int((now - job.updated_at).total_seconds())
                timeout = self._job_timeout_seconds(job)
                running_task = self._tasks.get(job.id)
                if running_task is not None and not running_task.done():
                    self._tombstones.add(job.id)
                    running_task.cancel()

                job.transition(JobStatus.FAILED)
                job.error = (
                    "Supervisor detected stale execution: "
                    f"no state change for {elapsed}s (timeout={timeout}s)."
                )
                self._registry.update(job, event_type="supervisor_stale")
                _log.warning(
                    "Supervisor marked stale job %s failed (%ss without updates, timeout=%ss)",
                    job.id,
                    elapsed,
                    timeout,
                )

            # 2) Retry terminal FAILED jobs with exponential backoff.
            for job in list(self._registry.by_status(JobStatus.FAILED)):
                task = self._tasks.get(job.id)
                if task is not None and not task.done():
                    # Do not interfere with active run loops.
                    continue

                params = self._run_params.get(job.id)
                if params is None:
                    # Cannot recover automatically without launch context.
                    continue

                # Don't retry jobs with unmet dependencies — _check_pending_deps
                # handles those when their deps complete.
                if job.depends_on and not self._registry.deps_satisfied(job):
                    continue

                state = self._supervisor_state_for(job.id)
                if state.supervisor_retries >= self._max_supervisor_retries:
                    if not state.human_notified:
                        state.human_notified = True
                        job.log_entries.append(
                            f"{now.isoformat()}: Supervisor escalated to human after "
                            f"{state.supervisor_retries} retries"
                        )
                        self._registry.update(job, event_type="supervisor_escalated")
                        notifications.append(
                            (params.notify, self._supervisor_human_diagnosis(job, state))
                        )
                    continue

                anchor = state.last_retry_at or job.updated_at
                backoff_seconds = self._supervisor_backoff_seconds(state.supervisor_retries)
                if (now - anchor).total_seconds() < backoff_seconds:
                    continue

                previous_error = (job.error or "").strip()

                # Reset model index so the primary model gets another chance
                # after cooldown. The in-loop fallback may have exhausted the
                # chain, leaving model_index stuck at the end.
                if job.model_chain:
                    job.model_index = 0
                    job.model = job.model_chain[0]

                action = "retry from primary model"
                if state.supervisor_retries >= 1:
                    previous_model = job.model
                    if job.advance_model():
                        action = f"switch model {previous_model} -> {job.model}"
                    else:
                        action = "retry current model (no fallback available)"

                state.supervisor_retries += 1
                state.last_retry_at = now
                state.human_notified = False

                if previous_error:
                    job.feedback = (
                        f"Supervisor recovery attempt {state.supervisor_retries}/"
                        f"{self._max_supervisor_retries}. Previous failure: {previous_error}"
                    )
                job.error = None
                job.transition(JobStatus.IN_PROGRESS)
                job.log_entries.append(
                    f"{now.isoformat()}: Supervisor retry {state.supervisor_retries}/"
                    f"{self._max_supervisor_retries} ({action})"
                )
                self._registry.update(job, event_type="supervisor_retry")
                self._launch(
                    job.id,
                    tools=params.tools,
                    notify=params.notify,
                    auth_storage=params.auth_storage,
                    output_format=params.output_format,
                )
                _log.warning(
                    "Supervisor retrying job %s (%s, retry %d/%d)",
                    job.id,
                    action,
                    state.supervisor_retries,
                    self._max_supervisor_retries,
                )

        for notify, message in notifications:
            try:
                await notify(message)
            except Exception as exc:
                _log.warning("Supervisor notification failed: %s", exc)

    def _reconcile_stale_jobs(self, *, force: bool = False) -> None:
        """Reconcile in-memory jobs with brainfile task files on disk.

        Otto persists delegated jobs as markdown files in ``brainfile/board/``.
        Users (or other processes) can delete or move these files while Otto is
        running. Without reconciliation, the Orchestrator's in-memory registry
        can keep showing jobs as READY/IN_PROGRESS even though their task files
        are gone.

        Policy:
        - DONE/CANCELLED jobs are allowed to be missing from board/ because they
          are archived to logs/.
        - Any other status must have a corresponding board/{id}.md file. If it
          doesn't, we treat the job as externally removed/archived and forget it.
        - If the job is currently running, we cancel its asyncio task and mark it
          tombstoned so the cancellation path cannot resurrect the task file.
        """

        if not self._lock.locked():
            raise RuntimeError("_reconcile_stale_jobs requires self._lock")

        now = time.monotonic()
        if not force and (now - self._last_reconcile_ts) < _RECONCILE_INTERVAL_SECONDS:
            return
        self._last_reconcile_ts = now

        for job in list(self._registry.all_jobs()):
            if job.status in (JobStatus.DONE, JobStatus.CANCELLED):
                continue

            path = BOARD_DIR / f"{job.id}.md"
            if path.exists():
                continue

            task = self._tasks.get(job.id)
            if task is not None and not task.done():
                self._tombstones.add(job.id)
                task.cancel()

            _log.info("Job %s removed externally; forgetting", job.id)
            self._registry.remove(job.id)
            self._evict_side_tables(job.id)

        # Drop tombstones for jobs that are no longer running.
        for job_id in list(self._tombstones):
            task = self._tasks.get(job_id)
            if task is None or task.done():
                self._tombstones.discard(job_id)

    async def delegate(
        self,
        contract: DelegationContract,
        chat_id: str,
        *,
        notify: Callable[[str], Awaitable[None]],
        tools: list[Any],
        subagent_name: str | None = None,
        session_model: str | None = None,
        model: str | None = None,
        model_chain: list[str] | None = None,
        bot_id: str | None = None,
        auth_storage: AuthStorage | None = None,
        output_format: str = "plain",
        depends_on: list[str] | None = None,
    ) -> str:
        """
        Spawn a sub-agent for the given contract and return the job ID immediately.

        If ``depends_on`` is set, the job stays PENDING until all dependency
        jobs reach DONE. When a dependency finishes, ``_check_pending_deps``
        is called to launch any newly-unblocked jobs.

        Returns:
            The 8-character job ID string.

        Raises:
            RuntimeError: If the parallel job cap is already reached.
        """
        self._maybe_start_supervisor()
        async with self._lock:
            self._reconcile_stale_jobs()
            job = DelegatedJob.create(
                contract=contract,
                chat_id=chat_id,
                model=model,
                model_chain=model_chain,
                subagent_name=subagent_name,
                output_format=output_format,
                bot_id=bot_id,
            )
            job.depends_on = depends_on or []
            self._snapshot_runtime_route(
                job,
                session_model=session_model,
                explicit_model_chain=model_chain,
            )
            params = _DeferredParams(
                tools=tools,
                notify=notify,
                auth_storage=auth_storage,
                output_format=output_format,
            )
            self._run_params[job.id] = params
            self._reset_supervisor_state(job.id)

            if job.depends_on and not self._registry.deps_satisfied(job):
                # Hold in READY — will be launched by _check_pending_deps
                self._registry.add(job)
                # Stash run params so we can launch later
                self._deferred[job.id] = params
                _log.info(
                    "Delegated job %s (deferred, waiting on %s): %s",
                    job.id,
                    job.depends_on,
                    contract.task[:80],
                )
                return job.id

            running = self._registry.running_count()
            if running >= self._max_parallel:
                raise RuntimeError(
                    f"Max parallel jobs ({self._max_parallel}) reached."
                    " Wait for existing jobs to complete or cancel one."
                )

            job.transition(JobStatus.IN_PROGRESS)
            self._registry.add(job)

        self._launch(
            job.id,
            tools=tools,
            notify=notify,
            auth_storage=auth_storage,
            output_format=output_format,
        )
        _log.info("Delegated job %s: %s", job.id, contract.task[:80])
        return job.id

    async def _run_job(
        self,
        job_id: str,
        *,
        tools: list[Any],
        notify: Callable[[str], Awaitable[None]],
        auth_storage: AuthStorage | None = None,
        output_format: str = "plain",
    ) -> None:
        """
        Background coroutine: run the sub-agent with rework loop.

        On success, transitions job to DELIVERED → DONE and notifies user.
        On validation failure, injects feedback and retries up to max_attempts.
        On exception or cancellation, transitions to terminal state and notifies.
        """
        async with self._lock:
            if job_id in self._tombstones:
                _log.debug("Job %s tombstoned; stopping background task", job_id)
                self._tombstones.discard(job_id)
                self._tasks.pop(job_id, None)
                return
            job = self._registry.get(job_id)

        if job is None:
            _log.error("Job %s not found when starting run", job_id)
            self._tasks.pop(job_id, None)
            return

        try:
            # Reuse existing baseline on retries to avoid blocking agent work
            if job.id not in self._job_validation_baselines:
                try:
                    baseline = await self._capture_baseline(job)
                except Exception as exc:
                    _log.warning(
                        "Validation baseline capture failed for job %s; "
                        "falling back to normal validation: %s",
                        job.id,
                        exc,
                    )
                    baseline = None
                if baseline is not None:
                    self._job_validation_baselines[job.id] = baseline

            self._snapshot_runtime_route(job)
            rate_limit_retry_counts: dict[str, int] = {}
            while job.attempts < job.max_attempts:
                current_model = job.model or "unknown"
                job.attempts += 1
                _log.info("Job %s attempt %d/%d", job.id, job.attempts, job.max_attempts)

                async with self._lock:
                    if job_id in self._tombstones:
                        return
                    job.transition(JobStatus.IN_PROGRESS)
                    self._registry.update(job, event_type="running")

                try:
                    outcome = _coerce_execution_outcome(
                        await self._execute_agent(
                            job,
                            tools=tools,
                            auth_storage=auth_storage,
                        )
                    )
                except Exception as exc:
                    mapped_exc = classify_otto_error(exc)

                    err_text = str(mapped_exc).lower()
                    is_rate_limit = isinstance(mapped_exc, OttoTransientError) and any(
                        marker in err_text
                        for marker in ("429", "rate limit", "ratelimit", "too many requests")
                    )
                    if is_rate_limit:
                        retry_count = rate_limit_retry_counts.get(current_model, 0)
                        if retry_count < _MAX_RATE_LIMIT_RETRIES:
                            retry_count += 1
                            rate_limit_retry_counts[current_model] = retry_count
                            # Rate-limit retries are transport-level recoveries; do not
                            # consume a full job attempt budget.
                            job.attempts = max(0, job.attempts - 1)
                            async with self._lock:
                                if job_id in self._tombstones:
                                    return
                                _log.warning(
                                    "Job %s rate-limited on %s (retry %d/%d)",
                                    job.id,
                                    current_model,
                                    retry_count,
                                    _MAX_RATE_LIMIT_RETRIES,
                                )
                                job.transition(JobStatus.IN_PROGRESS)
                                job.error = str(mapped_exc)
                                job.log_entries.append(
                                    f"{job.updated_at.isoformat()}: Rate-limit retry {retry_count}/{_MAX_RATE_LIMIT_RETRIES} on {current_model}"
                                )
                                self._registry.update(job, event_type="rate_limit_retry")
                            await asyncio.sleep(_RATE_LIMIT_RETRY_DELAY_SECONDS)
                            continue

                    switched = (
                        isinstance(mapped_exc, (OttoTransientError, OttoAuthError))
                        or (
                            is_acp_model(current_model)
                            and isinstance(mapped_exc, ACPTransportError)
                        )
                    ) and job.advance_model()
                    if switched:
                        # Model fallbacks are transport-level recoveries; do not
                        # consume a full job attempt budget (same as rate-limit).
                        job.attempts = max(0, job.attempts - 1)
                        route = self._runtime_routes.get(job.id)
                        if route is not None:
                            route.model_chain = list(job.model_chain)

                        async with self._lock:
                            if job_id in self._tombstones:
                                return
                            previous_model = (
                                job.model_chain[job.model_index - 1]
                                if job.model_index > 0
                                and job.model_index - 1 < len(job.model_chain)
                                else "unknown"
                            )
                            _log.warning(
                                "Job %s switching model after error (%s): %s -> %s",
                                job.id,
                                type(mapped_exc).__name__,
                                previous_model,
                                job.model,
                            )
                            job.transition(JobStatus.IN_PROGRESS)
                            job.error = str(mapped_exc)
                            job.log_entries.append(
                                f"{job.updated_at.isoformat()}: Model fallback {previous_model} -> {job.model}"
                            )
                            self._registry.update(job, event_type="model_fallback")
                        await asyncio.sleep(_MODEL_SWITCH_DELAY_SECONDS)
                        continue

                    if mapped_exc is exc:
                        raise
                    raise mapped_exc from exc

                async with self._lock:
                    if job_id in self._tombstones:
                        return
                    job.transition(JobStatus.DELIVERED)
                    job.result_summary = outcome.summary or _summarize_result_text(outcome.text)
                    self._registry.update(job, event_type="delivered")

                if outcome.blocker:
                    async with self._lock:
                        if job_id in self._tombstones:
                            return
                        job.transition(JobStatus.BLOCKED)
                        job.error = outcome.blocker
                        self._registry.update(job, event_type="blocked")
                    try:
                        await self._notify_completion(
                            job,
                            notify,
                            full_result=outcome.text,
                            output_format=output_format,
                            auth_storage=auth_storage,
                        )
                    except Exception as notify_exc:
                        _log.warning(
                            "Job %s blocker notification failed (job still BLOCKED): %s",
                            job.id,
                            notify_exc,
                        )
                    await self._check_pending_deps()
                    return

                validation_result = await self._validate(job)

                if validation_result.passed:
                    followup_scheduled = await self._schedule_followup_job(
                        job,
                        outcome,
                        notify=notify,
                        tools=tools,
                        auth_storage=auth_storage,
                        output_format=output_format,
                    )
                    async with self._lock:
                        if job_id in self._tombstones:
                            return
                        if validation_result.warning:
                            job.log_entries.append(
                                f"{datetime.now(UTC).isoformat()}: {validation_result.warning}"
                            )
                        job.error = None
                        job.feedback = None
                        job.transition(JobStatus.DONE)
                        self._reset_supervisor_state(job_id)
                        self._registry.update(job, event_type="done")
                    # Child tasks don't notify the user — only the parent does.
                    # ACP pipeline stages with follow-up jobs also suppress the
                    # intermediate notification so only the terminal stage speaks.
                    if not followup_scheduled and not self._registry.is_child(job):
                        try:
                            await self._notify_completion(
                                job,
                                notify,
                                full_result=outcome.text,
                                output_format=output_format,
                                auth_storage=auth_storage,
                            )
                        except Exception as notify_exc:
                            _log.warning(
                                "Job %s notification failed (job still DONE): %s",
                                job.id,
                                notify_exc,
                            )
                    await self._check_pending_deps()
                    return

                # Validation failed — prepare rework feedback
                validation_output = ""
                if validation_result.stdout:
                    validation_output += f"\nValidation stdout:\n{validation_result.stdout[-1500:]}"
                if validation_result.stderr:
                    validation_output += f"\nValidation stderr:\n{validation_result.stderr[-1500:]}"
                feedback = (
                    f"Attempt {job.attempts} failed validation.\n"
                    f"Failed command: {validation_result.command}\n"
                    f"Validation commands: {', '.join(job.contract.validation_cmds)}"
                    f"{validation_output}"
                )
                error = "Validation failed"

                if job.attempts < job.max_attempts:
                    # Retries remain — show as reworking, not failed
                    async with self._lock:
                        if job_id in self._tombstones:
                            return
                        job.transition(JobStatus.REWORKING)
                        job.feedback = feedback
                        job.error = error
                        self._registry.update(job, event_type="reworking")

                    _log.info(
                        "Job %s rework: attempt %d/%d, retrying",
                        job.id,
                        job.attempts,
                        job.max_attempts,
                    )
                    await asyncio.sleep(1)
                else:
                    # No retries left — terminal failure
                    async with self._lock:
                        if job_id in self._tombstones:
                            return
                        job.transition(JobStatus.FAILED)
                        job.feedback = feedback
                        job.error = error
                        self._registry.update(job, event_type="failed")

            # Exhausted all attempts
            if not self._registry.is_child(job):
                await self._notify_completion(
                    job,
                    notify,
                    output_format=output_format,
                    auth_storage=auth_storage,
                )
            await self._check_pending_deps()

        except asyncio.CancelledError:
            async with self._lock:
                if job_id in self._tombstones:
                    return
                job.transition(JobStatus.CANCELLED)
                self._reset_supervisor_state(job_id)
                self._registry.update(job, event_type="cancelled")
            if not self._registry.is_child(job):
                await self._notify_completion(
                    job,
                    notify,
                    output_format=output_format,
                    auth_storage=auth_storage,
                )
            await self._check_pending_deps()

        except Exception as exc:
            _log.exception("Job %s failed with exception", job_id)
            async with self._lock:
                if job_id in self._tombstones:
                    return
                job.transition(JobStatus.FAILED)
                job.error = str(exc)
                self._registry.update(job, event_type="failed")
            if not self._registry.is_child(job):
                await self._notify_completion(
                    job,
                    notify,
                    output_format=output_format,
                    auth_storage=auth_storage,
                )
            await self._check_pending_deps()

        finally:
            self._tasks.pop(job_id, None)
            self._tombstones.discard(job_id)

    async def _execute_agent(
        self, job: DelegatedJob, *, tools: list[Any], auth_storage: AuthStorage | None = None
    ) -> str | ACPExecutionResult:
        """
        Instantiate and run a sub-agent with an isolated message history.

        Uses otto.agent.run() with the contract as the opening prompt.
        The sub-agent's conversation is completely separate from the main chat.
        Injects feedback from previous attempts on rework. Enforces contract
        timeout via asyncio.wait_for if set.
        """
        from otto.agent import run
        from otto.prompts import build_system_prompt

        # If this is a parent task, gather child results for context injection
        child_results = None
        if job.children:
            child_results = []
            for child_id in job.children:
                child = self._registry.get(child_id)
                if child is None:
                    child = self._registry.load_job_from_logs(child_id)
                    if child is not None:
                        _log.warning(
                            "Recovered child result from logs for fan-in parent %s child %s",
                            job.id,
                            child_id,
                        )
                if child and child.result_summary:
                    child_results.append(
                        {
                            "id": child.id,
                            "task": child.contract.task[:120],
                            "result": child.result_summary,
                        }
                    )

        prompt = job.contract.to_prompt(
            feedback=job.feedback,
            attempt=job.attempts,
            child_results=child_results or None,
        )

        route = self._runtime_routes.get(job.id)
        if route is None:
            route = self._snapshot_runtime_route(job)

        model = job.model
        if model is None:
            raise RuntimeError(f"No resolved model for delegated job {job.id}")

        if is_acp_model(model):
            if route.subagent_prompt:
                prompt = (
                    "[INSTRUCTIONS — follow these for this response]\n"
                    f"{route.subagent_prompt}\n"
                    "[END INSTRUCTIONS]\n\n"
                    f"{prompt}"
                )
            return await self._run_acp_job(job, prompt=prompt)

        system = build_system_prompt(
            output_format="plain",
            active_tools=[t.name for t in tools],
        )
        if route.subagent_prompt:
            system = (
                f"{system}\n\n"
                "IMPORTANT SUBAGENT CONTEXT (apply throughout this task):\n"
                f"{route.subagent_prompt}"
            )

        messages: list[dict[str, Any]] = [{"role": "user", "content": prompt}]

        coro = run(
            model=model,
            system=system,
            messages=messages,
            tools=tools,
            auth_storage=auth_storage,
            agent_kind="delegated",
            delegation_id=job.id,
            agent_name=job.subagent_name or "main",
        )

        # Use contract timeout if set, otherwise fall back to orchestrator default
        timeout = (
            job.contract.timeout
            if job.contract.timeout and job.contract.timeout > 0
            else self._default_timeout
        )
        try:
            result = await asyncio.wait_for(coro, timeout=timeout)
        except TimeoutError:
            raise TimeoutError(f"Sub-agent timed out after {timeout}s") from None

        # agent.run() returns AgentResult with a .text attribute.
        return result.text or "(sub-agent produced no text output)"

    async def _run_acp_job(self, job: DelegatedJob, *, prompt: str) -> ACPExecutionResult:
        """Run a delegated job through an ephemeral ACP session."""
        model = job.model
        if model is None:
            raise RuntimeError(f"No resolved model for delegated job {job.id}")

        cwd = self._resolve_acp_cwd(job)
        timeout = self._acp_timeout_seconds(job)
        bridge = ACPOrchestrationBridge(
            allowed_tools=self._resolve_acp_tool_permissions(job),
        )

        def _log_tool_status(update: Any) -> None:
            _log.info(
                "Job %s acp tool status: %s (%s) [%s]",
                job.id,
                getattr(update, "name", ""),
                getattr(update, "status", ""),
                getattr(update, "call_id", ""),
            )

        await bridge.start()
        try:
            return await execute_acp_prompt(
                model=model,
                prompt=prompt,
                cwd=cwd,
                timeout_seconds=timeout,
                mcp_servers=[bridge.get_server_config()],
                signal_snapshot=bridge.snapshot,
                on_tool_status=_log_tool_status,
            )
        finally:
            await bridge.stop()

    async def _validate(self, job: DelegatedJob) -> _ValidationResult:
        """
        Run the contract's validation commands and return structured status.

        If no validation commands are specified, the job is considered valid
        by default (trusts the sub-agent's output).
        """

        cmds = job.contract.validation_cmds
        if not cmds:
            return _ValidationResult(passed=True, returncode=0)
        cwd = _resolve_validation_cwd(job)
        result = await self._run_validation_commands(
            job_id=job.id,
            cmds=cmds,
            cwd=cwd,
            mode="validation",
        )
        if result.passed:
            return result

        baseline = self._job_validation_baselines.get(job.id)
        if baseline is None:
            return result

        baseline_matches, match_reason = self._baseline_matches(
            baseline=baseline,
            validation_result=result,
        )
        if not baseline_matches:
            return result

        warning = (
            f"Validation failure for '{result.command}' matches pre-existing "
            f"baseline ({match_reason}); accepting result."
        )
        _log.warning("Job %s %s", job.id, warning)
        return _ValidationResult(
            passed=True,
            command=result.command,
            stdout=result.stdout,
            stderr=result.stderr,
            returncode=result.returncode,
            warning=warning,
        )

    @staticmethod
    def _baseline_matches(
        *,
        baseline: _ValidationBaseline,
        validation_result: _ValidationResult,
    ) -> tuple[bool, str]:
        current_returncode = validation_result.returncode
        if baseline.returncode == 0:
            return False, "baseline passed"
        if current_returncode is None or current_returncode == 0:
            return False, "post-agent validation no longer failing"

        # Returncode state is primary signal: baseline failed and current failed.
        reason = "both baseline and post-agent validation failed"

        # Secondary signal: when stdout signatures differ, treat as a new failure.
        baseline_stdout_key = baseline.stdout_key
        current_stdout_key = _validation_stdout_key(validation_result.stdout)
        if baseline_stdout_key and current_stdout_key:
            if baseline_stdout_key != current_stdout_key:
                return False, "stdout differs from baseline"
            reason += ", stdout matches baseline signature"
        return True, reason

    @staticmethod
    def _validation_cache_key(
        *,
        cwd: str | None,
        cmds: list[str],
    ) -> tuple[str | None, tuple[str, ...]]:
        return (cwd, tuple(cmds))

    def _get_cached_baseline(
        self,
        cache_key: tuple[str | None, tuple[str, ...]],
    ) -> _ValidationBaseline | None | object:
        entry = self._validation_baseline_cache.get(cache_key)
        if entry is None:
            return _BASELINE_CACHE_MISS
        if (time.monotonic() - entry.captured_at) > _VALIDATION_BASELINE_TTL_SECONDS:
            self._validation_baseline_cache.pop(cache_key, None)
            self._validation_baseline_locks.pop(cache_key, None)
            return _BASELINE_CACHE_MISS
        return entry.baseline

    async def _capture_baseline(self, job: DelegatedJob) -> _ValidationBaseline | None:
        cmds = list(job.contract.validation_cmds or [])
        if not cmds:
            return None

        cwd = _resolve_validation_cwd(job)
        cache_key = self._validation_cache_key(cwd=cwd, cmds=cmds)
        cached = self._get_cached_baseline(cache_key)
        if cached is not _BASELINE_CACHE_MISS:
            return cached

        lock = self._validation_baseline_locks.setdefault(cache_key, asyncio.Lock())
        async with lock:
            cached = self._get_cached_baseline(cache_key)
            if cached is not _BASELINE_CACHE_MISS:
                return cached

            result = await self._run_validation_commands(
                job_id=job.id,
                cmds=cmds,
                cwd=cwd,
                mode="baseline capture",
            )

            baseline: _ValidationBaseline | None
            if not result.passed and result.returncode is None:
                _log.warning(
                    "Validation baseline unavailable for job %s "
                    "(capture failed/timed out); using normal validation flow.",
                    job.id,
                )
                baseline = None
            else:
                baseline = _ValidationBaseline(
                    returncode=result.returncode or 0,
                    stdout_key=_validation_stdout_key(result.stdout),
                )
                if baseline.returncode != 0:
                    _log.warning(
                        "Captured failing validation baseline for job %s: %s (rc=%s)",
                        job.id,
                        result.command,
                        baseline.returncode,
                    )

            self._validation_baseline_cache[cache_key] = _ValidationBaselineCacheEntry(
                baseline=baseline,
                captured_at=time.monotonic(),
            )
            return baseline

    async def _run_validation_commands(
        self,
        *,
        job_id: str,
        cmds: list[str],
        cwd: str | None,
        mode: str,
    ) -> _ValidationResult:
        def _decode_output(data: bytes | None) -> str:
            return (data or b"").decode("utf-8", errors="replace")[-2000:]

        for cmd in cmds:
            stdout_bytes: bytes | None = b""
            stderr_bytes: bytes | None = b""
            try:
                args = shlex.split(cmd)
                if not args:
                    _log.warning("%s command is empty for job %s: %r", mode, job_id, cmd)
                    return _ValidationResult(
                        passed=False,
                        command=cmd,
                        returncode=None,
                    )
            except ValueError as exc:
                _log.warning(
                    "Invalid %s command for job %s (%s): %s",
                    mode,
                    job_id,
                    cmd,
                    exc,
                )
                return _ValidationResult(
                    passed=False,
                    command=cmd,
                    returncode=None,
                )

            try:
                proc = await asyncio.create_subprocess_exec(
                    *args,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                    cwd=cwd,
                )
                try:
                    stdout_bytes, stderr_bytes = await asyncio.wait_for(
                        proc.communicate(),
                        timeout=_VALIDATION_TIMEOUT_SECONDS,
                    )
                except TimeoutError:
                    _log.warning(
                        "%s timed out for job %s after %ss: %s",
                        mode,
                        job_id,
                        _VALIDATION_TIMEOUT_SECONDS,
                        cmd,
                    )
                    try:
                        proc.kill()
                    except ProcessLookupError:
                        pass
                    try:
                        stdout_bytes, stderr_bytes = await proc.communicate()
                    except Exception:
                        pass
                    return _ValidationResult(
                        passed=False,
                        command=cmd,
                        stdout=_decode_output(stdout_bytes),
                        stderr=_decode_output(stderr_bytes),
                        returncode=None,
                    )

                returncode = proc.returncode if isinstance(proc.returncode, int) else None
                if returncode != 0:
                    _log.warning("%s failed for job %s: %s", mode, job_id, cmd)
                    return _ValidationResult(
                        passed=False,
                        command=cmd,
                        stdout=_decode_output(stdout_bytes),
                        stderr=_decode_output(stderr_bytes),
                        returncode=returncode,
                    )
            except Exception as exc:
                _log.warning("%s error for job %s (%s): %s", mode, job_id, cmd, exc)
                return _ValidationResult(
                    passed=False,
                    command=cmd,
                    stdout=_decode_output(stdout_bytes),
                    stderr=_decode_output(stderr_bytes),
                    returncode=None,
                )

        return _ValidationResult(passed=True, returncode=0)

    async def _notify_completion(
        self,
        job: DelegatedJob,
        notify: Callable[[str], Awaitable[None]],
        *,
        full_result: str | None = None,
        output_format: str = "plain",
        auth_storage: AuthStorage | None = None,
    ) -> None:
        """Send a completion notification to the user.

        For DONE jobs, attempts an LLM polish pass to produce a natural,
        conversational message. Falls back to the mechanical format on failure.
        FAILED and CANCELLED jobs always use the mechanical format.
        """
        delivered_message = False

        if job.status == JobStatus.DONE:
            route = self._runtime_routes.get(job.id)
            polish_models = _dedupe_models(
                [route.session_model if route is not None else None, job.model]
            )

            for polish_model in polish_models:
                polished = await _polish_result(
                    job,
                    model=polish_model,
                    output_format=output_format,
                    auth_storage=auth_storage,
                )
                if polished:
                    await notify(polished)
                    delivered_message = True
                    break

        if not delivered_message:
            # Fallback: mechanical format
            msg = _format_notification(job, full_result=full_result, output_format=output_format)
            await notify(msg)

        route = self._runtime_routes.get(job.id)
        if route and route.warning and not route.warning_sent:
            route.warning_sent = True
            await notify(f"⚠️ Delegation routing warning: {route.warning}")

    def _launch(
        self,
        job_id: str,
        *,
        tools: list[Any],
        notify: Callable[[str], Awaitable[None]],
        auth_storage: AuthStorage | None = None,
        output_format: str = "plain",
    ) -> None:
        """Create an asyncio.Task for a job (must already be RUNNING)."""
        if job_id not in self._run_params:
            self._run_params[job_id] = _DeferredParams(
                tools=tools,
                notify=notify,
                auth_storage=auth_storage,
                output_format=output_format,
            )
        self._maybe_start_supervisor()
        task = asyncio.create_task(
            self._run_job(
                job_id,
                tools=tools,
                notify=notify,
                auth_storage=auth_storage,
                output_format=output_format,
            ),
            name=f"otto-job-{job_id}",
        )
        self._tasks[job_id] = task

    async def _check_pending_deps(self) -> None:
        """Launch any PENDING jobs whose dependencies are now satisfied.

        Called after every job completion. Also fails jobs whose deps failed.
        """
        async with self._lock:
            self._reconcile_stale_jobs()
            pending = self._registry.pending_with_deps()
            for job in pending:
                failed_dep = self._registry.deps_failed(job)
                if failed_dep is not None:
                    # Fan-in parents use on_child_failure; pipeline deps use on_dependency_failure
                    is_child_dep = failed_dep in job.children
                    disposition = (
                        job.contract.on_child_failure
                        if is_child_dep
                        else job.contract.on_dependency_failure
                    )
                    failed_dep_job = self._registry.get(failed_dep)
                    params = self._deferred.get(job.id)

                    if disposition == "retry_once" and failed_dep_job is not None:
                        retried = self._dep_retried.setdefault(job.id, set())
                        if failed_dep not in retried:
                            retried.add(failed_dep)
                            failed_dep_job.attempts = 0
                            failed_dep_job.error = None
                            failed_dep_job.feedback = None
                            self._reset_supervisor_state(failed_dep_job.id)
                            failed_dep_job.transition(JobStatus.IN_PROGRESS)
                            self._registry.update(failed_dep_job, event_type="dep_retry")
                            _log.warning(
                                "Dependency %s failed; auto-retrying (retry_once) for %s",
                                failed_dep,
                                job.id,
                            )
                            if params:
                                self._launch(
                                    failed_dep_job.id,
                                    tools=params.tools,
                                    notify=params.notify,
                                    auth_storage=params.auth_storage,
                                    output_format=params.output_format,
                                )
                        else:
                            _log.warning(
                                "Dependency %s already retried once; aborting %s",
                                failed_dep,
                                job.id,
                            )
                            job.transition(JobStatus.FAILED)
                            job.error = f"Dependency {failed_dep} failed after retry"
                            self._registry.update(job, event_type="dep_failed")
                            self._deferred.pop(job.id, None)
                            _log.info(
                                "Job %s dep-failed (retry exhausted for %s) — notification suppressed",
                                job.id,
                                failed_dep,
                            )
                        continue

                    if disposition == "skip":
                        # Proceed without the failed dependency — note gap in job log
                        _log.warning(
                            "Dependency %s failed; skipping and proceeding with %s",
                            failed_dep,
                            job.id,
                        )
                        job.log_entries.append(
                            f"Dependency {failed_dep} failed and was skipped "
                            f"(on_dependency_failure=skip)"
                        )
                        self._registry.update(job, event_type="dep_skipped")
                        # Fall through to deps_satisfied launch below
                        # by patching depends_on to exclude the failed dep
                        job.depends_on = [d for d in job.depends_on if d != failed_dep]
                        if not self._registry.deps_satisfied(job):
                            continue  # Still waiting on other deps
                        # All remaining deps satisfied — fall through to launch

                    else:
                        # abort (default)
                        job.transition(JobStatus.FAILED)
                        job.error = f"Dependency {failed_dep} failed"
                        self._registry.update(job, event_type="dep_failed")
                        self._deferred.pop(job.id, None)
                        _log.info(
                            "Job %s dep-failed (%s did not complete) — notification suppressed",
                            job.id,
                            failed_dep,
                        )
                        continue

                if self._registry.deps_satisfied(job):
                    if self._registry.running_count() >= self._max_parallel:
                        continue  # Wait for a slot
                    params = self._deferred.pop(job.id, None)
                    # Lazy reconstruction for jobs restored from disk after restart
                    if params is None and job.id in self._needs_reconstruction:
                        self._needs_reconstruction.discard(job.id)
                        try:
                            params = self._reconstruct_deferred_params(job)
                        except Exception as exc:
                            _log.warning(
                                "Lazy reconstruction failed for job %s: %s",
                                job.id,
                                exc,
                            )
                            job.transition(JobStatus.FAILED)
                            job.error = f"Startup reconstruction failed: {exc}"
                            self._registry.update(job, event_type="startup_reconstruct_failed")
                            continue
                    if params is None:
                        _log.warning(
                            "Job %s deps resolved but no launch params "
                            "(lost after restart?) — marking failed",
                            job.id,
                        )
                        job.transition(JobStatus.FAILED)
                        job.error = "Cannot resume after restart — re-delegate this task"
                        self._registry.update(job, event_type="orphaned")
                        continue
                    self._reset_supervisor_state(job.id)
                    job.transition(JobStatus.IN_PROGRESS)
                    self._registry.update(job, event_type="deps_resolved")
                    self._launch(
                        job.id,
                        tools=params.tools,
                        notify=params.notify,
                        auth_storage=params.auth_storage,
                        output_format=params.output_format,
                    )
                    _log.info("Job %s deps resolved, launched", job.id)

            self._cleanup_terminal_jobs()

    async def retry(
        self,
        job_id: str,
        *,
        notify: Callable[[str], Awaitable[None]],
        tools: list[Any],
        auth_storage: AuthStorage | None = None,
        output_format: str = "plain",
        feedback: str | None = None,
    ) -> bool:
        """Retry a failed job. Resets to RUNNING and re-launches.

        Returns True if the job was found and retried, False otherwise.
        """
        async with self._lock:
            self._reconcile_stale_jobs()
            job = self._registry.get(job_id)
            if job is None or job.status != JobStatus.FAILED:
                return False
            if feedback is not None:
                job.feedback = feedback
            else:
                job.feedback = None
            job.attempts = 0
            job.error = None
            self._reset_supervisor_state(job_id)
            job.transition(JobStatus.IN_PROGRESS)
            self._registry.update(job, event_type="retried")

        self._launch(
            job_id,
            tools=tools,
            notify=notify,
            auth_storage=auth_storage,
            output_format=output_format,
        )
        _log.info("Job %s retried", job_id)
        return True

    async def redirect(
        self,
        job_id: str,
        feedback: str,
        *,
        notify: Callable[[str], Awaitable[None]],
        tools: list[Any],
        subagent_name: str | None = None,
        session_model: str | None = None,
        auth_storage: AuthStorage | None = None,
        output_format: str = "plain",
    ) -> bool:
        """Redirect a running job: cancel it, inject feedback, re-launch.

        The job keeps the same ID. The user sees one job, not two.
        Returns True if the redirect succeeded.
        """
        async with self._lock:
            self._reconcile_stale_jobs()
            job = self._registry.get(job_id)
            if job is None:
                return False
            if job.status not in (JobStatus.IN_PROGRESS, JobStatus.FAILED):
                return False

        # Cancel the running task if active
        task = self._tasks.get(job_id)
        if task and not task.done():
            task.cancel()
            try:
                await asyncio.wait_for(asyncio.shield(task), timeout=2)
            except (asyncio.CancelledError, TimeoutError):
                pass

        async with self._lock:
            # Job may have been evicted by cleanup_terminal after cancellation;
            # re-insert the saved reference so the redirect can proceed.
            if self._registry.get(job_id) is None:
                board_path = BOARD_DIR / f"{job_id}.md"
                if job_id in self._tombstones:
                    return False
                if not board_path.exists():
                    _log.warning(
                        "Redirect aborted for job %s: board task file missing during reinsert",
                        job_id,
                    )
                    return False
                self._registry._jobs[job_id] = job
            job.feedback = feedback
            job.attempts = 0
            job.error = None
            if isinstance(subagent_name, str):
                normalized_subagent = subagent_name.strip()
                job.subagent_name = normalized_subagent or None
            self._snapshot_runtime_route(
                job,
                session_model=session_model,
                force=isinstance(subagent_name, str),
            )
            self._reset_supervisor_state(job_id)
            job.transition(JobStatus.IN_PROGRESS)
            self._registry.update(job, event_type="redirected")

        self._launch(
            job_id,
            tools=tools,
            notify=notify,
            auth_storage=auth_storage,
            output_format=output_format,
        )
        _log.info(
            "Job %s redirected with feedback: %s (subagent=%s)",
            job_id,
            feedback[:80],
            subagent_name,
        )
        return True

    async def cancel(self, job_id: str) -> bool:
        """
        Cancel a running job.

        Returns True if the job was found and cancellation was requested.
        Returns False if the job doesn't exist or is already terminal.
        """
        task = self._tasks.get(job_id)
        if task is None or task.done():
            return False
        task.cancel()
        return True

    async def cancel_routine(self, job_id: str) -> list[str]:
        """Cancel a job and all related jobs in its routine.

        Walks up parent_id to find the root, then cancels root + all
        descendants. Returns list of job IDs where cancellation was initiated.
        """
        async with self._lock:
            self._reconcile_stale_jobs()

            job = self._registry.get(job_id)
            if job is None:
                return []

            # Walk up to root
            root = job
            seen = {root.id}
            while root.parent_id and root.parent_id not in seen:
                parent = self._registry.get(root.parent_id)
                if parent is None:
                    break
                seen.add(parent.id)
                root = parent

            # Collect root + all descendants via BFS
            to_cancel: list[DelegatedJob] = []
            queue = [root]
            visited: set[str] = set()
            while queue:
                j = queue.pop(0)
                if j.id in visited:
                    continue
                visited.add(j.id)
                to_cancel.append(j)
                for child_id in j.children or []:
                    child = self._registry.get(child_id)
                    if child:
                        queue.append(child)

            # Cancel each non-terminal job
            cancelled: list[str] = []
            terminal = {JobStatus.DONE, JobStatus.CANCELLED, JobStatus.FAILED}
            for j in to_cancel:
                if j.status in terminal:
                    continue
                task = self._tasks.get(j.id)
                if task and not task.done():
                    task.cancel()
                else:
                    j.transition(JobStatus.CANCELLED)
                    self._registry.update(j, event_type="cancelled")
                    self._evict_side_tables(j.id)
                cancelled.append(j.id)

        return cancelled

    async def set_job_parent(self, job_id: str, parent_id: str) -> bool:
        """Set ``parent_id`` for a job. Returns False if job is missing/tombstoned."""
        async with self._lock:
            self._reconcile_stale_jobs()
            if job_id in self._tombstones or parent_id in self._tombstones:
                return False

            job = self._registry.get(job_id)
            if job is None:
                return False

            job.parent_id = parent_id
            self._registry.update(job, event_type="parent_set")
            return True

    async def link_child_to_parent(self, child_id: str, parent_id: str) -> bool:
        """Link child and parent jobs. Returns False if either is missing/tombstoned."""
        async with self._lock:
            self._reconcile_stale_jobs()
            if child_id in self._tombstones or parent_id in self._tombstones:
                return False

            child = self._registry.get(child_id)
            parent = self._registry.get(parent_id)
            if child is None or parent is None:
                return False

            child.parent_id = parent_id
            if child_id not in parent.children:
                parent.children.append(child_id)
            self._registry.update(parent, event_type="child_linked")
            self._registry.update(child, event_type="parent_set")
            return True

    async def list_jobs(self) -> list[dict[str, Any]]:
        """Return serialized info for all known jobs (any status)."""
        async with self._lock:
            self._reconcile_stale_jobs(force=True)
            return [j.to_dict() for j in self._registry.all_jobs()]

    async def list_running(self) -> list[dict[str, Any]]:
        """Return serialized info for currently running jobs only."""
        async with self._lock:
            self._reconcile_stale_jobs(force=True)
            return [j.to_dict() for j in self._registry.by_status(JobStatus.IN_PROGRESS)]

    async def get_job(self, job_id: str) -> dict[str, Any] | None:
        """Return serialized info for a single job, or None if not found."""
        async with self._lock:
            self._reconcile_stale_jobs(force=True)
            job = self._registry.get(job_id)
            return job.to_dict() if job else None


# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------

_orchestrator: Orchestrator | None = None


def get_orchestrator() -> Orchestrator:
    """Return the process-wide Orchestrator singleton (lazy-created).

    Reads ``delegation_timeout`` from AgentConfig if available.
    """
    global _orchestrator
    if _orchestrator is None:
        timeout = 1800
        try:
            from otto.config import load_config

            cfg = load_config()
            timeout = cfg.agent.delegation_timeout
        except Exception:
            pass
        _orchestrator = Orchestrator(default_timeout=timeout)
    return _orchestrator
