"""Main orchestration loop.

Wires all phases together: preflight → plan → assess → init → dispatch →
monitor → harvest → reunify → merge → report.

Provides ``run()`` (async) and ``run_sync()`` (blocking wrapper) entry points.
"""

from __future__ import annotations

import asyncio
import logging
import signal
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path

from cleave.core.assessment import assess_directive, detect_flags
from cleave.core.reunify import reunify_workspace
from cleave.core.workspace import generate_workspace_name, init_workspace
from cleave.orchestrator.config import OrchestratorConfig
from cleave.orchestrator.dispatcher import (
    dispatch_children,
    kill_all_children,
)
from cleave.orchestrator.errors import (
    CircuitBreakerTrippedError,
    OrchestratorError,
)
from cleave.orchestrator.merge import merge_all_branches
from cleave.orchestrator.planner import plan_split, validate_scopes
from cleave.orchestrator.prompt import build_child_prompt
from cleave.orchestrator.report import write_report
from cleave.orchestrator.state import (
    ChildState,
    OrchestratorState,
    load_state,
    reset_interrupted_children,
    save_state,
)
from cleave.orchestrator.worktree import (
    cleanup_worktrees,
    create_worktree,
    ensure_clean_worktree,
    get_current_branch,
    restore_dirty_state,
    snapshot_dirty_state,
)

logger = logging.getLogger(__name__)


@dataclass
class OrchestratorResult:
    """Return value from a completed orchestrator run."""

    success: bool
    state: OrchestratorState
    report_path: Path | None = None
    workspace_path: Path | None = None
    error: str | None = None


async def run(
    config: OrchestratorConfig,
    depth: int = 0,
    parent_run_id: str | None = None,
) -> OrchestratorResult:
    """Execute the full orchestration loop.

    This is the primary entry point. It runs all phases sequentially,
    persisting state at each transition for crash recovery.
    """
    config.validate()
    claude_cli = config.find_claude_cli()

    state = OrchestratorState.from_config(config, depth=depth, parent_run_id=parent_run_id)

    # Install signal handler for clean shutdown.
    # Uses loop.add_signal_handler for async safety — signal.signal()
    # would call asyncio.Event.set() from signal context, which is
    # not guaranteed safe and can deadlock if the signal arrives
    # while the logging lock is held.
    shutdown_event = asyncio.Event()
    loop = asyncio.get_running_loop()

    def _signal_handler() -> None:
        logger.warning("Signal received, initiating clean shutdown...")
        shutdown_event.set()

    loop.add_signal_handler(signal.SIGINT, _signal_handler)
    loop.add_signal_handler(signal.SIGTERM, _signal_handler)

    try:
        return await asyncio.wait_for(
            _run_phases(config, state, claude_cli, shutdown_event),
            timeout=config.timeout_seconds,
        )
    except TimeoutError:
        state.set_phase("failed")
        state.completed_at = datetime.now(UTC).isoformat()
        ws = Path(state.workspace_path) if state.workspace_path else None
        if ws and ws.exists():
            save_state(state, ws)
        try:
            await kill_all_children(state.children)
            await cleanup_worktrees(config.repo_path)
            if state.pre_dirty_head:
                await restore_dirty_state(config.repo_path, state.pre_dirty_head)
        except Exception:
            logger.debug("Cleanup failed during global timeout", exc_info=True)
        logger.error("Global timeout exceeded: %ds", config.timeout_seconds)
        return OrchestratorResult(
            success=False, state=state, workspace_path=ws,
            error=f"Global timeout exceeded ({config.timeout_seconds}s)",
        )
    except (OrchestratorError, Exception) as e:
        state.set_phase("failed")
        state.completed_at = datetime.now(UTC).isoformat()

        # Derive workspace_path from state (the local var is always None)
        ws = Path(state.workspace_path) if state.workspace_path else None
        report_path = None

        if ws and ws.exists():
            save_state(state, ws)
            report_path = write_report(state, ws)

        # Clean up worktrees on failure to avoid leaking them
        try:
            await cleanup_worktrees(config.repo_path)
            if state.pre_dirty_head:
                await restore_dirty_state(config.repo_path, state.pre_dirty_head)
        except Exception:
            logger.debug("Worktree cleanup failed during error handling", exc_info=True)

        if isinstance(e, OrchestratorError):
            logger.error("Orchestration failed: %s", e)
        else:
            logger.exception("Unexpected error during orchestration")

        return OrchestratorResult(
            success=False,
            state=state,
            report_path=report_path,
            workspace_path=ws,
            error=str(e),
        )
    finally:
        loop.remove_signal_handler(signal.SIGINT)
        loop.remove_signal_handler(signal.SIGTERM)


async def _run_phases(
    config: OrchestratorConfig,
    state: OrchestratorState,
    claude_cli: str,
    shutdown_event: asyncio.Event,
) -> OrchestratorResult:
    """Execute each phase in sequence."""

    # ── PREFLIGHT ──────────────────────────────────────────────────
    state.set_phase("preflight")
    if config.dirty:
        pre_head = await snapshot_dirty_state(config.repo_path)
        state.pre_dirty_head = pre_head
        logger.info("Dirty mode: snapshotted WIP at %s", pre_head[:12])
    else:
        await ensure_clean_worktree(config.repo_path)
    state.base_branch = await get_current_branch(config.repo_path)
    logger.info("Base branch: %s", state.base_branch)

    # ── PLANNING ───────────────────────────────────────────────────
    state.set_phase("planning")
    plan = await plan_split(claude_cli, config)
    state.plan = plan

    # Validate scope patterns against filesystem
    scope_warnings = validate_scopes(plan, config.repo_path)
    for warning in scope_warnings:
        logger.warning("Scope validation: %s", warning)

    if config.dry_run or config.confirm:
        label = "Dry run" if config.dry_run else "Confirm"
        logger.info(
            "%s — plan complete, not executing. "
            "NOTE: The planning phase above invoked claude --print and incurred API costs.",
            label,
        )
        phase = "complete" if config.dry_run else "planned"
        state.set_phase(phase)
        if config.dry_run:
            state.completed_at = datetime.now(UTC).isoformat()
        workspace_name = generate_workspace_name(config.directive, str(config.repo_path))
        workspace_path = config.repo_path / workspace_name
        workspace_path.mkdir(parents=True, exist_ok=True)
        state.workspace_path = str(workspace_path)
        save_state(state, workspace_path)
        report_path = write_report(state, workspace_path)

        if config.confirm:
            # Write human-readable plan summary for review
            _write_plan_review(plan, workspace_path)

        return OrchestratorResult(
            success=True,
            state=state,
            report_path=report_path,
            workspace_path=workspace_path,
        )

    children_plan = plan.get("children", [])
    child_labels = [c["label"] for c in children_plan]

    # ── ASSESSING ──────────────────────────────────────────────────
    state.set_phase("assessing")
    assessment = assess_directive(config.directive)
    state.assessment = {
        "complexity": assessment.complexity,
        "decision": assessment.decision,
        "pattern": assessment.pattern,
        "confidence": assessment.confidence,
    }

    # ── MODE DETECTION ────────────────────────────────────────────
    flags = detect_flags(config.directive)
    mode = "robust" if flags.get("robust") else "lean"

    # ── INITIALIZING ───────────────────────────────────────────────
    state.set_phase("initializing")
    ws_result = init_workspace(
        directive=config.directive,
        children=child_labels,
        output_dir=None,
        mode=mode,
        max_depth=config.max_depth,
        depth=state.depth,
    )

    if "error" in ws_result:
        raise OrchestratorError(f"Workspace init failed: {ws_result['error']}")

    workspace_path = Path(ws_result["workspace"])
    state.workspace_path = str(workspace_path)
    save_state(state, workspace_path)

    # Build ChildState objects (0-indexed to match workspace task files: 0-task.md, 1-task.md, ...)
    state.children = []
    for i, cp in enumerate(children_plan):
        child = ChildState(
            child_id=i,
            label=cp["label"],
            depends_on=cp.get("depends_on", []),
            branch_name=f"cleave/{cp['label']}",
        )
        state.children.append(child)

    save_state(state, workspace_path)

    if shutdown_event.is_set():
        return _shutdown_result(state, workspace_path)

    # ── DISPATCHING (create worktrees + build prompts) ─────────────
    state.set_phase("dispatching")
    save_state(state, workspace_path)

    prompts: dict[int, str] = {}
    worktree_paths: dict[int, Path] = {}

    # Find CLAUDE.md in the repo
    claude_md_path = config.repo_path / "CLAUDE.md"
    manifest_path = workspace_path / "manifest.yaml"
    siblings_path = workspace_path / "siblings.yaml"

    for child in state.children:
        # Create worktree (child_id ensures unique branch/path names)
        wt_path, branch = await create_worktree(
            config.repo_path, child.label, state.base_branch,
            child_id=child.child_id,
        )
        child.worktree_path = str(wt_path)
        child.branch_name = branch
        worktree_paths[child.child_id] = wt_path

        # Build prompt
        task_file = workspace_path / f"{child.child_id}-task.md"
        prompts[child.child_id] = build_child_prompt(
            task_file_path=task_file,
            manifest_path=manifest_path,
            claude_md_path=claude_md_path if claude_md_path.exists() else None,
            siblings_path=siblings_path,
            success_criteria=config.success_criteria,
            workspace_path=workspace_path,
            child_timeout_seconds=config.child_timeout_seconds,
            child_budget_usd=config.child_budget_usd,
            root_directive=config.root_directive,
        )

    save_state(state, workspace_path)

    if shutdown_event.is_set():
        return _shutdown_result(state, workspace_path)

    # ── MONITORING (dispatch and wait) ─────────────────────────────
    state.set_phase("monitoring")
    save_state(state, workspace_path)

    # Build task file path map for child environment
    task_file_paths = {
        child.child_id: workspace_path / f"{child.child_id}-task.md"
        for child in state.children
    }

    try:
        await dispatch_children(
            claude_cli=claude_cli,
            children=state.children,
            prompts=prompts,
            worktree_paths=worktree_paths,
            config=config,
            total_cost_so_far=state.total_cost_usd,
            workspace_path=workspace_path,
            task_file_paths=task_file_paths,
            depth=state.depth,
            run_id=state.run_id,
            repo_path=config.repo_path,
        )
    except Exception:
        await kill_all_children(state.children)
        save_state(state, workspace_path)
        raise

    # ── HARVESTING ─────────────────────────────────────────────────
    state.set_phase("harvesting")

    # Update cost tracking
    for child in state.children:
        if child.cost_usd:
            state.total_cost_usd += child.cost_usd

    # Check circuit breaker
    for child in state.children:
        if child.status == "failed":
            state.consecutive_failures += 1
        elif child.status == "completed":
            state.consecutive_failures = 0

        if state.consecutive_failures >= config.circuit_breaker_threshold:
            save_state(state, workspace_path)
            raise CircuitBreakerTrippedError(
                state.consecutive_failures, config.circuit_breaker_threshold
            )

    # Check for NEEDS_DECOMPOSITION — detect from task file status
    for child in state.children:
        if child.status == "completed":
            task_file = workspace_path / f"{child.child_id}-task.md"
            if task_file.exists():
                content = task_file.read_text(encoding="utf-8")
                if "NEEDS_DECOMPOSITION" in content:
                    child.status = "needs_decomposition"

    # Handle recursive decomposition
    needs_decomp = [c for c in state.children if c.status == "needs_decomposition"]
    if needs_decomp and state.depth < config.max_depth:
        for child in needs_decomp:
            logger.info(
                "Child %d (%s) needs decomposition — recursing at depth %d",
                child.child_id, child.label, state.depth + 1,
            )
            child_config = OrchestratorConfig(
                directive=f"{child.label}: {config.directive}",
                repo_path=config.repo_path,
                root_directive=config.root_directive,
                success_criteria=config.success_criteria,
                model=config.model,
                planner_model=config.planner_model,
                max_budget_usd=config.max_budget_usd - state.total_cost_usd,
                child_budget_usd=config.child_budget_usd,
                timeout_seconds=config.timeout_seconds,
                child_timeout_seconds=config.child_timeout_seconds,
                max_depth=config.max_depth,
                circuit_breaker_threshold=config.circuit_breaker_threshold,
                max_parallel_children=config.max_parallel_children,
                permission_mode=config.permission_mode,
                allowed_tools=config.allowed_tools,
            )
            sub_result = await run(
                child_config,
                depth=state.depth + 1,
                parent_run_id=state.run_id,
            )
            if sub_result.success:
                child.status = "completed"
            else:
                child.status = "failed"
                child.error_message = sub_result.error

    save_state(state, workspace_path)

    # ── REUNIFYING ─────────────────────────────────────────────────
    state.set_phase("reunifying")
    save_state(state, workspace_path)

    completed_children = [c for c in state.children if c.status == "completed"]
    if completed_children:
        # Collect task result paths
        result_paths = []
        for child in completed_children:
            task_file = workspace_path / f"{child.child_id}-task.md"
            if task_file.exists():
                result_paths.append(str(task_file))

        if result_paths:
            reuni = reunify_workspace(str(workspace_path))
            state.reunification = reuni
    else:
        state.reunification = {"status": "FAILED", "note": "No children completed successfully"}

    save_state(state, workspace_path)

    # ── MERGING ────────────────────────────────────────────────────
    state.set_phase("merging")
    save_state(state, workspace_path)

    merge_ok = True
    completed_branches = [c.branch_name for c in state.children if c.status == "completed"]
    if completed_branches:
        merge_report = await merge_all_branches(
            config.repo_path, completed_branches, state.base_branch
        )
        state.merge_report = {
            "all_succeeded": merge_report.all_succeeded,
            "results": [
                {
                    "branch": r.branch,
                    "success": r.success,
                    "conflict_files": r.conflict_files,
                    "error": r.error,
                }
                for r in merge_report.results
            ],
        }
        if not merge_report.all_succeeded:
            merge_ok = False
            failed_branches = [r.branch for r in merge_report.failed]
            logger.error("Merge failed for: %s", failed_branches)

    # Clean up worktrees
    await cleanup_worktrees(config.repo_path)

    # Restore dirty state if we snapshotted it
    if state.pre_dirty_head:
        await restore_dirty_state(config.repo_path, state.pre_dirty_head)

    # ── COMPLETE ───────────────────────────────────────────────────
    all_children_ok = all(c.status == "completed" for c in state.children)
    all_ok = all_children_ok and merge_ok
    state.set_phase("complete" if all_ok else "failed")
    state.completed_at = datetime.now(UTC).isoformat()
    save_state(state, workspace_path)

    report_path = write_report(state, workspace_path)

    error = None
    if not all_children_ok and not merge_ok:
        error = "Some children failed and merge conflicts occurred"
    elif not all_children_ok:
        error = "Some children failed"
    elif not merge_ok:
        error = "Merge conflicts occurred"

    return OrchestratorResult(
        success=all_ok,
        state=state,
        report_path=report_path,
        workspace_path=workspace_path,
        error=error,
    )


def _shutdown_result(state: OrchestratorState, workspace_path: Path) -> OrchestratorResult:
    """Build result for a clean shutdown (SIGINT/SIGTERM)."""
    state.completed_at = datetime.now(UTC).isoformat()
    save_state(state, workspace_path)
    report_path = write_report(state, workspace_path)
    logger.info("Clean shutdown complete. Resume with: cleave run --resume %s", workspace_path)
    return OrchestratorResult(
        success=False,
        state=state,
        report_path=report_path,
        workspace_path=workspace_path,
        error="Interrupted by signal — resume with --resume",
    )


def _write_plan_review(plan: dict, workspace_path: Path) -> Path:
    """Write a human-readable plan summary for --confirm review."""
    lines = ["# Plan Review", ""]
    rationale = plan.get("rationale", "")
    if rationale:
        lines.append(f"**Rationale**: {rationale}")
        lines.append("")
    for i, child in enumerate(plan.get("children", [])):
        lines.append(f"## Child {i}: {child.get('label', '?')}")
        lines.append("")
        lines.append(child.get("description", "(no description)"))
        scope = child.get("scope", [])
        if scope:
            lines.append("")
            lines.append("**Scope**: " + ", ".join(f"`{s}`" for s in scope))
        deps = child.get("depends_on", [])
        if deps:
            lines.append(f"**Depends on**: {', '.join(deps)}")
        lines.append("")
    lines.append("---")
    lines.append("To proceed: `cleave run --resume " + str(workspace_path) + "`")
    lines.append("")

    review_path = workspace_path / "plan-review.md"
    review_path.write_text("\n".join(lines), encoding="utf-8")
    logger.info("Plan review written to %s", review_path)
    return review_path


async def _resume_from_plan(
    config: OrchestratorConfig,
    state: OrchestratorState,
    claude_cli: str,
    shutdown_event: asyncio.Event,
    workspace_path: Path,
) -> OrchestratorResult:
    """Continue execution from a saved plan (--confirm resume)."""
    plan = state.plan
    children_plan = plan.get("children", [])
    child_labels = [c["label"] for c in children_plan]

    # ── ASSESSING ──────────────────────────────────────────────────
    state.set_phase("assessing")
    assessment = assess_directive(config.directive)
    state.assessment = {
        "complexity": assessment.complexity,
        "decision": assessment.decision,
        "pattern": assessment.pattern,
        "confidence": assessment.confidence,
    }

    # ── MODE DETECTION ────────────────────────────────────────────
    flags = detect_flags(config.directive)
    mode = "robust" if flags.get("robust") else "lean"

    # ── INITIALIZING ───────────────────────────────────────────────
    state.set_phase("initializing")
    ws_result = init_workspace(
        directive=config.directive,
        children=child_labels,
        output_dir=str(workspace_path),
        mode=mode,
        max_depth=config.max_depth,
        depth=state.depth,
    )
    if "error" in ws_result:
        raise OrchestratorError(f"Workspace init failed: {ws_result['error']}")

    state.workspace_path = str(workspace_path)
    save_state(state, workspace_path)

    state.children = []
    for i, cp in enumerate(children_plan):
        child = ChildState(
            child_id=i,
            label=cp["label"],
            depends_on=cp.get("depends_on", []),
            branch_name=f"cleave/{cp['label']}",
        )
        state.children.append(child)
    save_state(state, workspace_path)

    if shutdown_event.is_set():
        return _shutdown_result(state, workspace_path)

    # Continue with dispatch → monitor → harvest → reunify → merge
    # (reuse the _run_phases tail by calling the remaining phases inline)
    return await _run_post_plan_phases(config, state, claude_cli, shutdown_event, workspace_path)


async def _run_post_plan_phases(
    config: OrchestratorConfig,
    state: OrchestratorState,
    claude_cli: str,
    shutdown_event: asyncio.Event,
    workspace_path: Path,
) -> OrchestratorResult:
    """Execute phases from dispatching onward (shared by run and resume-from-plan)."""
    # ── DISPATCHING ────────────────────────────────────────────────
    state.set_phase("dispatching")
    save_state(state, workspace_path)

    prompts: dict[int, str] = {}
    worktree_paths: dict[int, Path] = {}

    claude_md_path = config.repo_path / "CLAUDE.md"
    manifest_path = workspace_path / "manifest.yaml"
    siblings_path = workspace_path / "siblings.yaml"

    for child in state.children:
        wt_path, branch = await create_worktree(
            config.repo_path, child.label, state.base_branch,
            child_id=child.child_id,
        )
        child.worktree_path = str(wt_path)
        child.branch_name = branch
        worktree_paths[child.child_id] = wt_path

        task_file = workspace_path / f"{child.child_id}-task.md"
        prompts[child.child_id] = build_child_prompt(
            task_file_path=task_file,
            manifest_path=manifest_path,
            claude_md_path=claude_md_path if claude_md_path.exists() else None,
            siblings_path=siblings_path,
            success_criteria=config.success_criteria,
            workspace_path=workspace_path,
            child_timeout_seconds=config.child_timeout_seconds,
            child_budget_usd=config.child_budget_usd,
            root_directive=config.root_directive,
        )

    save_state(state, workspace_path)

    if shutdown_event.is_set():
        return _shutdown_result(state, workspace_path)

    # ── MONITORING ─────────────────────────────────────────────────
    state.set_phase("monitoring")
    save_state(state, workspace_path)

    task_file_paths = {
        child.child_id: workspace_path / f"{child.child_id}-task.md"
        for child in state.children
    }

    try:
        await dispatch_children(
            claude_cli=claude_cli,
            children=state.children,
            prompts=prompts,
            worktree_paths=worktree_paths,
            config=config,
            total_cost_so_far=state.total_cost_usd,
            workspace_path=workspace_path,
            task_file_paths=task_file_paths,
            depth=state.depth,
            run_id=state.run_id,
            repo_path=config.repo_path,
        )
    except Exception:
        await kill_all_children(state.children)
        save_state(state, workspace_path)
        raise

    # ── HARVESTING ─────────────────────────────────────────────────
    state.set_phase("harvesting")
    for child in state.children:
        if child.cost_usd:
            state.total_cost_usd += child.cost_usd

    for child in state.children:
        if child.status == "failed":
            state.consecutive_failures += 1
        elif child.status == "completed":
            state.consecutive_failures = 0
        if state.consecutive_failures >= config.circuit_breaker_threshold:
            save_state(state, workspace_path)
            raise CircuitBreakerTrippedError(
                state.consecutive_failures, config.circuit_breaker_threshold
            )

    for child in state.children:
        if child.status == "completed":
            task_file = workspace_path / f"{child.child_id}-task.md"
            if task_file.exists():
                content = task_file.read_text(encoding="utf-8")
                if "NEEDS_DECOMPOSITION" in content:
                    child.status = "needs_decomposition"

    save_state(state, workspace_path)

    # ── REUNIFYING ─────────────────────────────────────────────────
    state.set_phase("reunifying")
    save_state(state, workspace_path)
    completed_children = [c for c in state.children if c.status == "completed"]
    if completed_children:
        result_paths = []
        for child in completed_children:
            task_file = workspace_path / f"{child.child_id}-task.md"
            if task_file.exists():
                result_paths.append(str(task_file))
        if result_paths:
            reuni = reunify_workspace(str(workspace_path))
            state.reunification = reuni
    else:
        state.reunification = {"status": "FAILED", "note": "No children completed successfully"}
    save_state(state, workspace_path)

    # ── MERGING ────────────────────────────────────────────────────
    state.set_phase("merging")
    save_state(state, workspace_path)

    merge_ok = True
    completed_branches = [c.branch_name for c in state.children if c.status == "completed"]
    if completed_branches:
        merge_report = await merge_all_branches(
            config.repo_path, completed_branches, state.base_branch
        )
        state.merge_report = {
            "all_succeeded": merge_report.all_succeeded,
            "results": [
                {
                    "branch": r.branch,
                    "success": r.success,
                    "conflict_files": r.conflict_files,
                    "error": r.error,
                }
                for r in merge_report.results
            ],
        }
        if not merge_report.all_succeeded:
            merge_ok = False
            failed_branches = [r.branch for r in merge_report.failed]
            logger.error("Merge failed for: %s", failed_branches)

    await cleanup_worktrees(config.repo_path)

    # Restore dirty state if we snapshotted it
    if state.pre_dirty_head:
        await restore_dirty_state(config.repo_path, state.pre_dirty_head)

    # ── COMPLETE ───────────────────────────────────────────────────
    all_children_ok = all(c.status == "completed" for c in state.children)
    all_ok = all_children_ok and merge_ok
    state.set_phase("complete" if all_ok else "failed")
    state.completed_at = datetime.now(UTC).isoformat()
    save_state(state, workspace_path)
    report_path = write_report(state, workspace_path)

    error = None
    if not all_children_ok and not merge_ok:
        error = "Some children failed and merge conflicts occurred"
    elif not all_children_ok:
        error = "Some children failed"
    elif not merge_ok:
        error = "Merge conflicts occurred"

    return OrchestratorResult(
        success=all_ok,
        state=state,
        report_path=report_path,
        workspace_path=workspace_path,
        error=error,
    )


async def resume(workspace_path: Path) -> OrchestratorResult:
    """Resume an interrupted orchestrator run from saved state."""
    state = load_state(workspace_path)
    config = OrchestratorConfig.from_dict(state.config)

    reset_count = reset_interrupted_children(state)
    if reset_count:
        logger.info("Reset %d interrupted children to pending", reset_count)

    config.validate()
    claude_cli = config.find_claude_cli()

    # Resume from --confirm gate: continue with full execution
    if state.phase == "planned":
        logger.info("Resuming from --confirm gate, continuing execution")
        # Re-run from assessing phase with the saved plan
        config.confirm = False  # Don't stop again
        config.dry_run = False
        new_state = OrchestratorState.from_config(config, depth=state.depth, parent_run_id=state.parent_run_id)
        new_state.run_id = state.run_id
        new_state.base_branch = state.base_branch
        new_state.plan = state.plan
        shutdown_event = asyncio.Event()
        loop = asyncio.get_running_loop()
        loop.add_signal_handler(signal.SIGINT, lambda: shutdown_event.set())
        loop.add_signal_handler(signal.SIGTERM, lambda: shutdown_event.set())
        try:
            return await _resume_from_plan(config, new_state, claude_cli, shutdown_event, workspace_path)
        finally:
            loop.remove_signal_handler(signal.SIGINT)
            loop.remove_signal_handler(signal.SIGTERM)

    # Re-enter at dispatching phase if we have pending children
    pending = [c for c in state.children if c.status == "pending"]
    if pending:
        logger.info("Resuming with %d pending children", len(pending))
        # Rebuild prompts and worktrees for pending children
        prompts: dict[int, str] = {}
        worktree_paths: dict[int, Path] = {}

        claude_md_path = config.repo_path / "CLAUDE.md"
        manifest_path = workspace_path / "manifest.yaml"
        siblings_path = workspace_path / "siblings.yaml"

        for child in pending:
            wt_path, branch = await create_worktree(
                config.repo_path, child.label, state.base_branch,
                child_id=child.child_id,
            )
            child.worktree_path = str(wt_path)
            child.branch_name = branch
            worktree_paths[child.child_id] = wt_path

            task_file = workspace_path / f"{child.child_id}-task.md"
            prompts[child.child_id] = build_child_prompt(
                task_file_path=task_file,
                manifest_path=manifest_path,
                claude_md_path=claude_md_path if claude_md_path.exists() else None,
                siblings_path=siblings_path,
                success_criteria=config.success_criteria,
                workspace_path=workspace_path,
                child_timeout_seconds=config.child_timeout_seconds,
                child_budget_usd=config.child_budget_usd,
                root_directive=config.root_directive,
            )

        state.set_phase("monitoring")
        save_state(state, workspace_path)

        resume_task_file_paths = {
            child.child_id: workspace_path / f"{child.child_id}-task.md"
            for child in pending
        }
        await dispatch_children(
            claude_cli=claude_cli,
            children=pending,
            prompts=prompts,
            worktree_paths=worktree_paths,
            config=config,
            total_cost_so_far=state.total_cost_usd,
            workspace_path=workspace_path,
            task_file_paths=resume_task_file_paths,
            depth=state.depth,
            run_id=state.run_id,
            repo_path=config.repo_path,
        )

        # Only count costs for newly-dispatched children (not already-counted)
        for child in pending:
            if child.cost_usd:
                state.total_cost_usd += child.cost_usd

    # Continue with remaining phases
    completed_children = [c for c in state.children if c.status == "completed"]
    completed_branches = [c.branch_name for c in completed_children]

    if completed_children and state.phase in ("monitoring", "harvesting", "reunifying"):
        # Reunify before merging (was previously skipped on resume)
        state.set_phase("reunifying")
        save_state(state, workspace_path)
        if completed_children:
            reuni = reunify_workspace(str(workspace_path))
            state.reunification = reuni
        save_state(state, workspace_path)

    merge_ok = True
    if completed_branches and state.phase in ("reunifying", "merging"):
        state.set_phase("merging")
        save_state(state, workspace_path)
        merge_report = await merge_all_branches(
            config.repo_path, completed_branches, state.base_branch
        )
        state.merge_report = {
            "all_succeeded": merge_report.all_succeeded,
            "results": [
                {
                    "branch": r.branch,
                    "success": r.success,
                    "conflict_files": r.conflict_files,
                    "error": r.error,
                }
                for r in merge_report.results
            ],
        }
        if not merge_report.all_succeeded:
            merge_ok = False
        await cleanup_worktrees(config.repo_path)

    all_children_ok = all(c.status == "completed" for c in state.children)
    all_ok = all_children_ok and merge_ok
    state.set_phase("complete" if all_ok else "failed")
    state.completed_at = datetime.now(UTC).isoformat()
    save_state(state, workspace_path)
    report_path = write_report(state, workspace_path)

    error = None
    if not all_children_ok and not merge_ok:
        error = "Some children failed and merge conflicts occurred"
    elif not all_children_ok:
        error = "Some children failed"
    elif not merge_ok:
        error = "Merge conflicts occurred"

    return OrchestratorResult(
        success=all_ok,
        state=state,
        report_path=report_path,
        workspace_path=workspace_path,
        error=error,
    )


def run_sync(config: OrchestratorConfig) -> OrchestratorResult:
    """Synchronous wrapper for the async ``run()`` entry point."""
    return asyncio.run(run(config))


def resume_sync(workspace_path: Path) -> OrchestratorResult:
    """Synchronous wrapper for the async ``resume()`` entry point."""
    return asyncio.run(resume(workspace_path))
