"""SQLite-based task storage."""

import json
import logging
import os
import re
import sqlite3
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

__all__ = [
    "KNOWN_FAILURE_REASONS",
    "Task",
    "TaskStats",
    "TaskCycle",
    "TaskCycleIteration",
    "SqliteTaskStore",
    "extract_failure_reason",
]


# Known failure reason categories
KNOWN_FAILURE_REASONS = {"MAX_STEPS", "MAX_TURNS", "TEST_FAILURE", "UNKNOWN"}

_FAILURE_MARKER_RE = re.compile(r"\[GZA_FAILURE:(\w+)\]")


def _compute_percentiles(values: list[float]) -> dict | None:
    """Compute min/max/avg/median/p90 for a list of numeric values.

    Returns None if the list is empty.

    Note: For samples with fewer than 10 values, ``p90`` equals ``max``.
    This is a deliberate conservative choice — the nearest-rank formula
    would give the same result for most small samples anyway, and returning
    ``max`` avoids the misleading impression of a precise 90th-percentile
    estimate from very few data points.
    """
    if not values:
        return None
    sorted_vals = sorted(values)
    n = len(sorted_vals)
    total = sum(sorted_vals)
    avg = total / n

    # Median: average of two middle elements for even n
    mid = n // 2
    if n % 2 == 1:
        median = sorted_vals[mid]
    else:
        median = (sorted_vals[mid - 1] + sorted_vals[mid]) / 2

    # p90: 90th percentile via nearest-rank method
    p90_idx = max(0, int(0.9 * n) - 1)
    p90 = sorted_vals[p90_idx] if n >= 10 else sorted_vals[-1]

    return {
        "min": sorted_vals[0],
        "max": sorted_vals[-1],
        "avg": round(avg, 2),
        "median": median,
        "p90": p90,
        "count": n,
    }


@dataclass
class Task:
    """A task in the database."""
    id: int | None  # None for unsaved tasks
    prompt: str
    status: str = "pending"  # pending, in_progress, completed, failed, unmerged, dropped
    task_type: str = "implement"  # explore, plan, implement, review, improve
    task_id: str | None = None  # YYYYMMDD-slug format
    branch: str | None = None
    log_file: str | None = None
    report_file: str | None = None
    based_on: int | None = None  # Reference to parent task id
    has_commits: bool | None = None
    duration_seconds: float | None = None
    num_steps_reported: int | None = None  # Step count reported by the provider
    num_steps_computed: int | None = None  # Step count computed internally
    num_turns_reported: int | None = None  # Turn count reported by the provider
    num_turns_computed: int | None = None  # Turn count computed internally
    cost_usd: float | None = None
    input_tokens: int | None = None   # Total input tokens (including cache tokens)
    output_tokens: int | None = None  # Total output tokens
    created_at: datetime | None = None
    started_at: datetime | None = None
    completed_at: datetime | None = None
    # New fields for task import/chaining
    group: str | None = None  # Group name for related tasks
    depends_on: int | None = None  # Task ID this task depends on
    spec: str | None = None  # Path to spec file for context
    create_review: bool = False  # Auto-create review task on completion
    same_branch: bool = False  # Continue on depends_on task's branch instead of creating new
    task_type_hint: str | None = None  # Explicit branch type hint (e.g., "fix", "feature")
    output_content: str | None = None  # Actual content of report/plan/review (for persistence)
    session_id: str | None = None  # Claude session ID for resume capability
    pr_number: int | None = None  # GitHub PR number
    model: str | None = None  # Per-task model override
    provider: str | None = None  # Per-task provider override
    merge_status: str | None = None  # None, 'unmerged', or 'merged'
    failure_reason: str | None = None
    skip_learnings: bool = False
    diff_files_changed: int | None = None  # Files changed vs. main (v13)
    diff_lines_added: int | None = None    # Lines added vs. main (v13)
    diff_lines_removed: int | None = None  # Lines removed vs. main (v13)
    review_cleared_at: datetime | None = None  # When review state was cleared by an improve task (v14)
    log_schema_version: int = 1  # 1=legacy logs, 2=message-step logs
    cycle_id: int | None = None  # Cycle this task belongs to (v18)
    cycle_iteration_index: int | None = None  # Iteration index within cycle (v18)
    cycle_role: str | None = None  # 'review' or 'improve' within a cycle (v18)

    def is_explore(self) -> bool:
        """Check if this is an exploration task."""
        return self.task_type == "explore"

    def is_blocked(self) -> bool:
        """Check if this task is blocked by a dependency."""
        return self.depends_on is not None


@dataclass
class TaskStats:
    """Statistics from a task run."""
    duration_seconds: float | None = None
    num_steps_reported: int | None = None  # Step count reported by the provider
    num_steps_computed: int | None = None  # Step count computed internally
    num_turns_reported: int | None = None  # Turn count reported by the provider
    num_turns_computed: int | None = None  # Turn count computed internally
    cost_usd: float | None = None
    input_tokens: int | None = None   # Total input tokens (including cache tokens)
    output_tokens: int | None = None  # Total output tokens
    tokens_estimated: bool = False
    cost_estimated: bool = False


@dataclass
class TaskCycle:
    """A cycle of review/improve iterations for an implementation task."""
    id: int
    implementation_task_id: int
    status: str  # active | approved | maxed_out | blocked
    max_iterations: int
    started_at: datetime
    ended_at: datetime | None
    stop_reason: str | None  # approved | max_iterations | needs_discussion | review_failed | improve_failed


@dataclass
class TaskCycleIteration:
    """A single iteration within a cycle (one review + optional improve)."""
    id: int
    cycle_id: int
    iteration_index: int
    review_task_id: int | None
    review_verdict: str | None
    improve_task_id: int | None
    state: str  # review_created | review_completed | improve_created | improve_completed | terminal
    started_at: datetime
    ended_at: datetime | None


# Migration from v18 to v19
MIGRATION_V18_TO_V19 = """
CREATE INDEX IF NOT EXISTS idx_tasks_type_based_on ON tasks(task_type, based_on);
CREATE UNIQUE INDEX IF NOT EXISTS uq_task_cycle_iterations_cycle_iter ON task_cycle_iterations(cycle_id, iteration_index);
"""

# Migration from v19 to v20
MIGRATION_V19_TO_V20 = "UPDATE tasks SET task_type='implement' WHERE task_type='task';"

# Schema version for migrations
SCHEMA_VERSION = 20

SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_version (
    version INTEGER PRIMARY KEY
);

CREATE TABLE IF NOT EXISTS tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    prompt TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    task_type TEXT NOT NULL DEFAULT 'implement',
    task_id TEXT,
    branch TEXT,
    log_file TEXT,
    report_file TEXT,
    based_on INTEGER REFERENCES tasks(id),
    has_commits INTEGER,
    duration_seconds REAL,
    num_steps_reported INTEGER,
    num_steps_computed INTEGER,
    num_turns INTEGER,  -- kept for backward compat; use num_turns_reported instead
    num_turns_reported INTEGER,
    num_turns_computed INTEGER,
    cost_usd REAL,
    created_at TEXT NOT NULL,
    started_at TEXT,
    completed_at TEXT,
    -- New fields for task import/chaining (v2)
    "group" TEXT,
    depends_on INTEGER REFERENCES tasks(id),
    spec TEXT,
    create_review INTEGER DEFAULT 0,
    -- New field for task chaining (v3)
    same_branch INTEGER DEFAULT 0,
    -- New fields (v4)
    task_type_hint TEXT,
    output_content TEXT,
    -- New field for task resume (v5)
    session_id TEXT,
    -- New field for PR tracking (v6)
    pr_number INTEGER,
    -- New fields for per-task model/provider overrides (v7)
    model TEXT,
    provider TEXT,
    -- num_turns_reported and num_turns_computed added inline above (v8)
    -- Raw token counts for cost recalculation (v9)
    input_tokens INTEGER,
    output_tokens INTEGER,
    -- Merge status tracking (v10)
    merge_status TEXT,
    -- Failure reason tracking (v11)
    failure_reason TEXT,
    -- Skip learnings injection (v12)
    skip_learnings INTEGER DEFAULT 0,
    -- Diff stats vs. main branch (v13)
    diff_files_changed INTEGER,
    diff_lines_added INTEGER,
    diff_lines_removed INTEGER,
    -- Review state cleared by improve task (v14)
    review_cleared_at TEXT,
    -- Log entry schema marker (v17): 1=legacy turn/event logs, 2=message-step logs
    log_schema_version INTEGER DEFAULT 1,
    -- Cycle tracking (v18): nullable FK and metadata for cycle orchestration
    cycle_id INTEGER,
    cycle_iteration_index INTEGER,
    cycle_role TEXT
);

CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_task_id ON tasks(task_id);
CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at);
CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks("group");
CREATE INDEX IF NOT EXISTS idx_tasks_depends_on ON tasks(depends_on);
CREATE INDEX IF NOT EXISTS idx_tasks_merge_status ON tasks(merge_status);

CREATE TABLE IF NOT EXISTS run_steps (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    run_id INTEGER NOT NULL REFERENCES tasks(id),
    step_index INTEGER NOT NULL,
    step_id TEXT NOT NULL,
    provider TEXT NOT NULL,
    message_role TEXT NOT NULL,
    message_text TEXT,
    started_at TEXT NOT NULL,
    completed_at TEXT,
    outcome TEXT,
    summary TEXT,
    legacy_turn_id TEXT,
    legacy_event_id TEXT,
    UNIQUE(run_id, step_index),
    UNIQUE(run_id, step_id)
);

CREATE TABLE IF NOT EXISTS run_substeps (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    run_id INTEGER NOT NULL REFERENCES tasks(id),
    step_id INTEGER NOT NULL REFERENCES run_steps(id),
    substep_index INTEGER NOT NULL,
    substep_id TEXT NOT NULL,
    type TEXT NOT NULL,
    source TEXT NOT NULL,
    call_id TEXT,
    payload_json TEXT NOT NULL,
    timestamp TEXT NOT NULL,
    legacy_turn_id TEXT,
    legacy_event_id TEXT,
    UNIQUE(step_id, substep_index),
    UNIQUE(step_id, substep_id)
);

CREATE INDEX IF NOT EXISTS idx_run_steps_run_id ON run_steps(run_id);
CREATE INDEX IF NOT EXISTS idx_run_steps_step_index ON run_steps(run_id, step_index);
CREATE INDEX IF NOT EXISTS idx_run_substeps_run_id ON run_substeps(run_id);
CREATE INDEX IF NOT EXISTS idx_run_substeps_step_id ON run_substeps(step_id);

CREATE TABLE IF NOT EXISTS task_cycles (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    implementation_task_id INTEGER NOT NULL REFERENCES tasks(id),
    status TEXT NOT NULL DEFAULT 'active',
    max_iterations INTEGER NOT NULL DEFAULT 3,
    started_at TEXT NOT NULL,
    ended_at TEXT,
    stop_reason TEXT
);

CREATE TABLE IF NOT EXISTS task_cycle_iterations (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    cycle_id INTEGER NOT NULL REFERENCES task_cycles(id),
    iteration_index INTEGER NOT NULL,
    review_task_id INTEGER REFERENCES tasks(id),
    review_verdict TEXT,
    improve_task_id INTEGER REFERENCES tasks(id),
    state TEXT NOT NULL DEFAULT 'review_created',
    started_at TEXT NOT NULL,
    ended_at TEXT
);

CREATE INDEX IF NOT EXISTS idx_task_cycles_impl_id ON task_cycles(implementation_task_id);
CREATE INDEX IF NOT EXISTS idx_task_cycles_status ON task_cycles(status);
CREATE INDEX IF NOT EXISTS idx_task_cycle_iterations_cycle_idx ON task_cycle_iterations(cycle_id, iteration_index);
CREATE UNIQUE INDEX IF NOT EXISTS uq_task_cycle_iterations_cycle_iter ON task_cycle_iterations(cycle_id, iteration_index);
CREATE INDEX IF NOT EXISTS idx_tasks_cycle_id ON tasks(cycle_id);
CREATE INDEX IF NOT EXISTS idx_tasks_type_based_on ON tasks(task_type, based_on);
"""

# Migration from v1 to v2
MIGRATION_V1_TO_V2 = """
ALTER TABLE tasks ADD COLUMN "group" TEXT;
ALTER TABLE tasks ADD COLUMN depends_on INTEGER REFERENCES tasks(id);
ALTER TABLE tasks ADD COLUMN spec TEXT;
ALTER TABLE tasks ADD COLUMN create_review INTEGER DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks("group");
CREATE INDEX IF NOT EXISTS idx_tasks_depends_on ON tasks(depends_on);
"""

# Migration from v2 to v3
MIGRATION_V2_TO_V3 = """
ALTER TABLE tasks ADD COLUMN same_branch INTEGER DEFAULT 0;
"""

# Migration from v3 to v4
MIGRATION_V3_TO_V4 = """
ALTER TABLE tasks ADD COLUMN task_type_hint TEXT;
ALTER TABLE tasks ADD COLUMN output_content TEXT;
"""

# Migration from v4 to v5
MIGRATION_V4_TO_V5 = """
ALTER TABLE tasks ADD COLUMN session_id TEXT;
"""

# Migration from v5 to v6
MIGRATION_V5_TO_V6 = """
ALTER TABLE tasks ADD COLUMN pr_number INTEGER;
"""

# Migration from v6 to v7
MIGRATION_V6_TO_V7 = """
ALTER TABLE tasks ADD COLUMN model TEXT;
ALTER TABLE tasks ADD COLUMN provider TEXT;
"""

# Migration from v7 to v8
MIGRATION_V7_TO_V8 = """
ALTER TABLE tasks ADD COLUMN num_turns_reported INTEGER;
ALTER TABLE tasks ADD COLUMN num_turns_computed INTEGER;
UPDATE tasks SET num_turns_reported = num_turns WHERE num_turns IS NOT NULL;
"""

# Migration from v8 to v9
MIGRATION_V8_TO_V9 = """
ALTER TABLE tasks ADD COLUMN input_tokens INTEGER;
ALTER TABLE tasks ADD COLUMN output_tokens INTEGER;
"""

# Migration from v9 to v10
MIGRATION_V9_TO_V10 = """
ALTER TABLE tasks ADD COLUMN merge_status TEXT;
CREATE INDEX IF NOT EXISTS idx_tasks_merge_status ON tasks(merge_status);
"""

# Migration from v10 to v11
MIGRATION_V10_TO_V11 = """
ALTER TABLE tasks ADD COLUMN failure_reason TEXT;
UPDATE tasks SET failure_reason = 'UNKNOWN' WHERE status = 'failed';
"""

# Migration from v11 to v12
MIGRATION_V11_TO_V12 = """
ALTER TABLE tasks ADD COLUMN skip_learnings INTEGER DEFAULT 0;
"""

# Migration from v12 to v13
MIGRATION_V12_TO_V13 = """
ALTER TABLE tasks ADD COLUMN diff_files_changed INTEGER;
ALTER TABLE tasks ADD COLUMN diff_lines_added INTEGER;
ALTER TABLE tasks ADD COLUMN diff_lines_removed INTEGER;
"""

# Migration from v13 to v14
MIGRATION_V13_TO_V14 = """
ALTER TABLE tasks ADD COLUMN review_cleared_at TEXT;
"""

# Migration from v14 to v15
MIGRATION_V14_TO_V15 = """
ALTER TABLE tasks ADD COLUMN num_steps_reported INTEGER;
ALTER TABLE tasks ADD COLUMN num_steps_computed INTEGER;
UPDATE tasks
SET num_steps_reported = num_turns_reported
WHERE num_steps_reported IS NULL AND num_turns_reported IS NOT NULL;
UPDATE tasks
SET num_steps_computed = num_turns_computed
WHERE num_steps_computed IS NULL AND num_turns_computed IS NOT NULL;
"""

# Migration from v15 to v16
MIGRATION_V15_TO_V16 = """
CREATE TABLE IF NOT EXISTS run_steps (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    run_id INTEGER NOT NULL REFERENCES tasks(id),
    step_index INTEGER NOT NULL,
    step_id TEXT NOT NULL,
    provider TEXT NOT NULL,
    message_role TEXT NOT NULL,
    message_text TEXT,
    started_at TEXT NOT NULL,
    completed_at TEXT,
    outcome TEXT,
    summary TEXT,
    legacy_turn_id TEXT,
    legacy_event_id TEXT,
    UNIQUE(run_id, step_index),
    UNIQUE(run_id, step_id)
);
CREATE TABLE IF NOT EXISTS run_substeps (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    run_id INTEGER NOT NULL REFERENCES tasks(id),
    step_id INTEGER NOT NULL REFERENCES run_steps(id),
    substep_index INTEGER NOT NULL,
    substep_id TEXT NOT NULL,
    type TEXT NOT NULL,
    source TEXT NOT NULL,
    call_id TEXT,
    payload_json TEXT NOT NULL,
    timestamp TEXT NOT NULL,
    legacy_turn_id TEXT,
    legacy_event_id TEXT,
    UNIQUE(step_id, substep_index),
    UNIQUE(step_id, substep_id)
);
CREATE INDEX IF NOT EXISTS idx_run_steps_run_id ON run_steps(run_id);
CREATE INDEX IF NOT EXISTS idx_run_steps_step_index ON run_steps(run_id, step_index);
CREATE INDEX IF NOT EXISTS idx_run_substeps_run_id ON run_substeps(run_id);
CREATE INDEX IF NOT EXISTS idx_run_substeps_step_id ON run_substeps(step_id);
"""

# Migration from v16 to v17
MIGRATION_V16_TO_V17 = """
ALTER TABLE tasks ADD COLUMN log_schema_version INTEGER DEFAULT 1;
UPDATE tasks SET log_schema_version = 1 WHERE log_schema_version IS NULL;
"""

# Migration from v17 to v18
MIGRATION_V17_TO_V18 = """
ALTER TABLE tasks ADD COLUMN cycle_id INTEGER;
ALTER TABLE tasks ADD COLUMN cycle_iteration_index INTEGER;
ALTER TABLE tasks ADD COLUMN cycle_role TEXT;
CREATE TABLE IF NOT EXISTS task_cycles (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    implementation_task_id INTEGER NOT NULL REFERENCES tasks(id),
    status TEXT NOT NULL DEFAULT 'active',
    max_iterations INTEGER NOT NULL DEFAULT 3,
    started_at TEXT NOT NULL,
    ended_at TEXT,
    stop_reason TEXT
);
CREATE TABLE IF NOT EXISTS task_cycle_iterations (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    cycle_id INTEGER NOT NULL REFERENCES task_cycles(id),
    iteration_index INTEGER NOT NULL,
    review_task_id INTEGER REFERENCES tasks(id),
    review_verdict TEXT,
    improve_task_id INTEGER REFERENCES tasks(id),
    state TEXT NOT NULL DEFAULT 'review_created',
    started_at TEXT NOT NULL,
    ended_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_task_cycles_impl_id ON task_cycles(implementation_task_id);
CREATE INDEX IF NOT EXISTS idx_task_cycles_status ON task_cycles(status);
CREATE INDEX IF NOT EXISTS idx_task_cycle_iterations_cycle_idx ON task_cycle_iterations(cycle_id, iteration_index);
CREATE INDEX IF NOT EXISTS idx_tasks_cycle_id ON tasks(cycle_id);
"""


@dataclass(frozen=True)
class StepRef:
    """Opaque step reference used when writing substeps/finalization."""

    id: int
    run_id: int
    step_index: int
    step_id: str


@dataclass(frozen=True)
class RunStep:
    """Persisted top-level message step for a run."""

    id: int
    run_id: int
    step_index: int
    step_id: str
    provider: str
    message_role: str
    message_text: str | None
    started_at: datetime
    completed_at: datetime | None
    outcome: str | None
    summary: str | None
    legacy_turn_id: str | None
    legacy_event_id: str | None


@dataclass(frozen=True)
class RunSubstep:
    """Persisted substep/tool event under a top-level message step."""

    id: int
    run_id: int
    step_id: int
    substep_index: int
    substep_id: str
    type: str
    source: str
    call_id: str | None
    payload: Any
    timestamp: datetime
    legacy_turn_id: str | None
    legacy_event_id: str | None


def extract_failure_reason(log_file_path: Path) -> str:
    """Scan a log file for failure reason markers.

    Looks for the pattern [GZA_FAILURE:REASON] and returns the last match.
    Validates against the known set; returns 'UNKNOWN' if no valid match found.

    Args:
        log_file_path: Path to the log file to scan.

    Returns:
        A failure reason string from KNOWN_FAILURE_REASONS.
    """
    if not log_file_path.exists():
        return "UNKNOWN"

    try:
        content = log_file_path.read_text(errors="replace")
    except OSError:
        return "UNKNOWN"

    last_reason = None
    for match in _FAILURE_MARKER_RE.finditer(content):
        reason = match.group(1)
        if reason in KNOWN_FAILURE_REASONS:
            last_reason = reason

    return last_reason if last_reason is not None else "UNKNOWN"

_MIGRATIONS: list[tuple[int, str]] = [
    (2, MIGRATION_V1_TO_V2),
    (3, MIGRATION_V2_TO_V3),
    (4, MIGRATION_V3_TO_V4),
    (5, MIGRATION_V4_TO_V5),
    (6, MIGRATION_V5_TO_V6),
    (7, MIGRATION_V6_TO_V7),
    (8, MIGRATION_V7_TO_V8),
    (9, MIGRATION_V8_TO_V9),
    (10, MIGRATION_V9_TO_V10),
    (11, MIGRATION_V10_TO_V11),
    (12, MIGRATION_V11_TO_V12),
    (13, MIGRATION_V12_TO_V13),
    (14, MIGRATION_V13_TO_V14),
    (15, MIGRATION_V14_TO_V15),
    (16, MIGRATION_V15_TO_V16),
    (17, MIGRATION_V16_TO_V17),
    (18, MIGRATION_V17_TO_V18),
    (19, MIGRATION_V18_TO_V19),
    (20, MIGRATION_V19_TO_V20),
]


class SqliteTaskStore:
    """SQLite-based task storage."""

    def __init__(self, db_path: Path):
        self.db_path = db_path
        self._ensure_db()

    def _ensure_db(self) -> None:
        """Ensure database exists and schema is current."""
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        with self._connect() as conn:
            # Check if schema_version table exists
            cur = conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
            )
            if cur.fetchone() is None:
                # Fresh database - create full schema
                conn.executescript(SCHEMA)
                conn.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
            else:
                # Check current version and migrate if needed
                cur = conn.execute("SELECT version FROM schema_version LIMIT 1")
                row = cur.fetchone()
                current_version = row["version"] if row else 0

                for target_version, migration_sql in _MIGRATIONS:
                    if current_version < target_version:
                        for stmt in migration_sql.strip().split(";"):
                            stmt = stmt.strip()
                            if stmt:
                                try:
                                    conn.execute(stmt)
                                except sqlite3.OperationalError:
                                    # Column/table/index might already exist
                                    pass
                        conn.execute("UPDATE schema_version SET version = ?", (target_version,))
                        current_version = target_version

                if row is None:
                    conn.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))

    def _connect(self) -> sqlite3.Connection:
        """Create a database connection with auto-commit."""
        conn = sqlite3.connect(self.db_path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        return conn

    def _row_to_task(self, row: sqlite3.Row) -> Task:
        """Convert a database row to a Task."""
        return Task(
            id=row["id"],
            prompt=row["prompt"].decode("utf-8", errors="replace") if isinstance(row["prompt"], bytes) else row["prompt"],
            status=row["status"],
            task_type=row["task_type"],
            task_id=row["task_id"],
            branch=row["branch"],
            log_file=row["log_file"],
            report_file=row["report_file"],
            based_on=row["based_on"],
            has_commits=bool(row["has_commits"]) if row["has_commits"] is not None else None,
            duration_seconds=row["duration_seconds"],
            num_steps_reported=row["num_steps_reported"] if "num_steps_reported" in row.keys() else None,
            num_steps_computed=row["num_steps_computed"] if "num_steps_computed" in row.keys() else None,
            num_turns_reported=row["num_turns_reported"] if "num_turns_reported" in row.keys() else None,
            num_turns_computed=row["num_turns_computed"] if "num_turns_computed" in row.keys() else None,
            cost_usd=row["cost_usd"],
            input_tokens=row["input_tokens"] if "input_tokens" in row.keys() else None,
            output_tokens=row["output_tokens"] if "output_tokens" in row.keys() else None,
            created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else None,
            started_at=datetime.fromisoformat(row["started_at"]) if row["started_at"] else None,
            completed_at=datetime.fromisoformat(row["completed_at"]) if row["completed_at"] else None,
            group=row["group"],
            depends_on=row["depends_on"],
            spec=row["spec"],
            create_review=bool(row["create_review"]) if row["create_review"] is not None else False,
            same_branch=bool(row["same_branch"]) if row["same_branch"] is not None else False,
            task_type_hint=row["task_type_hint"] if "task_type_hint" in row.keys() else None,
            output_content=row["output_content"] if "output_content" in row.keys() else None,
            session_id=row["session_id"] if "session_id" in row.keys() else None,
            pr_number=row["pr_number"] if "pr_number" in row.keys() else None,
            model=row["model"] if "model" in row.keys() else None,
            provider=row["provider"] if "provider" in row.keys() else None,
            merge_status=row["merge_status"] if "merge_status" in row.keys() else None,
            failure_reason=row["failure_reason"] if "failure_reason" in row.keys() else None,
            skip_learnings=bool(row["skip_learnings"]) if "skip_learnings" in row.keys() and row["skip_learnings"] is not None else False,
            diff_files_changed=row["diff_files_changed"] if "diff_files_changed" in row.keys() else None,
            diff_lines_added=row["diff_lines_added"] if "diff_lines_added" in row.keys() else None,
            diff_lines_removed=row["diff_lines_removed"] if "diff_lines_removed" in row.keys() else None,
            review_cleared_at=datetime.fromisoformat(row["review_cleared_at"]) if "review_cleared_at" in row.keys() and row["review_cleared_at"] else None,
            log_schema_version=(
                row["log_schema_version"]
                if "log_schema_version" in row.keys() and row["log_schema_version"] is not None
                else 1
            ),
            cycle_id=row["cycle_id"] if "cycle_id" in row.keys() else None,
            cycle_iteration_index=row["cycle_iteration_index"] if "cycle_iteration_index" in row.keys() else None,
            cycle_role=row["cycle_role"] if "cycle_role" in row.keys() else None,
        )

    def _row_to_run_step(self, row: sqlite3.Row) -> RunStep:
        """Convert a database row to a RunStep."""
        return RunStep(
            id=row["id"],
            run_id=row["run_id"],
            step_index=row["step_index"],
            step_id=row["step_id"],
            provider=row["provider"],
            message_role=row["message_role"],
            message_text=row["message_text"],
            started_at=datetime.fromisoformat(row["started_at"]),
            completed_at=datetime.fromisoformat(row["completed_at"]) if row["completed_at"] else None,
            outcome=row["outcome"],
            summary=row["summary"],
            legacy_turn_id=row["legacy_turn_id"],
            legacy_event_id=row["legacy_event_id"],
        )

    def _row_to_run_substep(self, row: sqlite3.Row) -> RunSubstep:
        """Convert a database row to a RunSubstep."""
        payload: Any
        try:
            payload = json.loads(row["payload_json"])
        except (TypeError, json.JSONDecodeError):
            payload = row["payload_json"]

        return RunSubstep(
            id=row["id"],
            run_id=row["run_id"],
            step_id=row["step_id"],
            substep_index=row["substep_index"],
            substep_id=row["substep_id"],
            type=row["type"],
            source=row["source"],
            call_id=row["call_id"],
            payload=payload,
            timestamp=datetime.fromisoformat(row["timestamp"]),
            legacy_turn_id=row["legacy_turn_id"],
            legacy_event_id=row["legacy_event_id"],
        )

    # === Task CRUD ===

    def add(
        self,
        prompt: str,
        task_type: str = "implement",
        based_on: int | None = None,
        group: str | None = None,
        depends_on: int | None = None,
        spec: str | None = None,
        create_review: bool = False,
        same_branch: bool = False,
        task_type_hint: str | None = None,
        model: str | None = None,
        provider: str | None = None,
        skip_learnings: bool = False,
    ) -> Task:
        """Add a new task."""
        now = datetime.now(timezone.utc).isoformat()
        with self._connect() as conn:
            cur = conn.execute(
                """
                INSERT INTO tasks (prompt, task_type, based_on, created_at, "group", depends_on, spec, create_review, same_branch, task_type_hint, model, provider, skip_learnings)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (prompt, task_type, based_on, now, group, depends_on, spec, 1 if create_review else 0, 1 if same_branch else 0, task_type_hint, model, provider, 1 if skip_learnings else 0),
            )
            task_id = cur.lastrowid
            assert task_id is not None
            result = self.get(task_id)
            assert result is not None
            return result

    def get(self, task_id: int) -> Task | None:
        """Get a task by ID."""
        with self._connect() as conn:
            cur = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,))
            row = cur.fetchone()
            return self._row_to_task(row) if row else None

    def get_by_task_id(self, task_id: str) -> Task | None:
        """Get a task by task_id (slug)."""
        with self._connect() as conn:
            cur = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,))
            row = cur.fetchone()
            return self._row_to_task(row) if row else None

    def update(self, task: Task) -> None:
        """Update a task."""
        with self._connect() as conn:
            conn.execute(
                """
                UPDATE tasks SET
                    prompt = ?,
                    status = ?,
                    task_type = ?,
                    task_id = ?,
                    branch = ?,
                    log_file = ?,
                    report_file = ?,
                    based_on = ?,
                    has_commits = ?,
                    duration_seconds = ?,
                    num_steps_reported = ?,
                    num_steps_computed = ?,
                    num_turns_reported = ?,
                    num_turns_computed = ?,
                    cost_usd = ?,
                    input_tokens = ?,
                    output_tokens = ?,
                    started_at = ?,
                    completed_at = ?,
                    "group" = ?,
                    depends_on = ?,
                    spec = ?,
                    create_review = ?,
                    same_branch = ?,
                    output_content = ?,
                    session_id = ?,
                    pr_number = ?,
                    model = ?,
                    provider = ?,
                    merge_status = ?,
                    failure_reason = ?,
                    skip_learnings = ?,
                    diff_files_changed = ?,
                    diff_lines_added = ?,
                    diff_lines_removed = ?,
                    review_cleared_at = ?,
                    log_schema_version = ?,
                    cycle_id = ?,
                    cycle_iteration_index = ?,
                    cycle_role = ?
                WHERE id = ?
                """,
                (
                    task.prompt,
                    task.status,
                    task.task_type,
                    task.task_id,
                    task.branch,
                    task.log_file,
                    task.report_file,
                    task.based_on,
                    1 if task.has_commits else (0 if task.has_commits is False else None),
                    task.duration_seconds,
                    task.num_steps_reported,
                    task.num_steps_computed,
                    task.num_turns_reported,
                    task.num_turns_computed,
                    task.cost_usd,
                    task.input_tokens,
                    task.output_tokens,
                    task.started_at.isoformat() if task.started_at else None,
                    task.completed_at.isoformat() if task.completed_at else None,
                    task.group,
                    task.depends_on,
                    task.spec,
                    1 if task.create_review else 0,
                    1 if task.same_branch else 0,
                    task.output_content,
                    task.session_id,
                    task.pr_number,
                    task.model,
                    task.provider,
                    task.merge_status,
                    task.failure_reason,
                    1 if task.skip_learnings else 0,
                    task.diff_files_changed,
                    task.diff_lines_added,
                    task.diff_lines_removed,
                    task.review_cleared_at.isoformat() if task.review_cleared_at else None,
                    task.log_schema_version,
                    task.cycle_id,
                    task.cycle_iteration_index,
                    task.cycle_role,
                    task.id,
                ),
            )

    def delete(self, task_id: int) -> bool:
        """Delete a task by ID. Returns True if deleted."""
        with self._connect() as conn:
            cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
            return cur.rowcount > 0

    # === Query methods ===

    def get_next_pending(self) -> Task | None:
        """Get the next pending task (oldest first), skipping blocked tasks.

        A task is unblocked when its dependency is completed OR when its
        dependency is failed but a completed retry exists anywhere in the
        based_on chain.
        """
        with self._connect() as conn:
            cur = conn.execute(
                """
                WITH RECURSIVE successful_ancestors(id) AS (
                    SELECT id FROM tasks WHERE status = 'completed'
                    UNION ALL
                    SELECT t2.based_on FROM tasks t2
                    JOIN successful_ancestors sa ON t2.id = sa.id
                    WHERE t2.based_on IS NOT NULL
                )
                SELECT t.* FROM tasks t
                WHERE t.status = 'pending'
                AND (
                    t.depends_on IS NULL
                    OR t.depends_on IN (SELECT id FROM successful_ancestors)
                )
                ORDER BY t.created_at ASC
                LIMIT 1
                """
            )
            row = cur.fetchone()
            return self._row_to_task(row) if row else None

    def claim_next_pending_task(self) -> Task | None:
        """Atomically claim the next pending task by marking it in_progress.

        This is used by background workers to ensure only one worker
        picks up a given task, even in concurrent scenarios.

        Returns:
            The claimed task, or None if no tasks are available
        """
        with self._connect() as conn:
            # Use a transaction to atomically claim the task
            conn.execute("BEGIN IMMEDIATE")
            try:
                # Find next pending task
                cur = conn.execute(
                    """
                    WITH RECURSIVE successful_ancestors(id) AS (
                        SELECT id FROM tasks WHERE status = 'completed'
                        UNION ALL
                        SELECT t2.based_on FROM tasks t2
                        JOIN successful_ancestors sa ON t2.id = sa.id
                        WHERE t2.based_on IS NOT NULL
                    )
                    SELECT t.* FROM tasks t
                    WHERE t.status = 'pending'
                    AND (
                        t.depends_on IS NULL
                        OR t.depends_on IN (SELECT id FROM successful_ancestors)
                    )
                    ORDER BY t.created_at ASC
                    LIMIT 1
                    """
                )
                row = cur.fetchone()

                if not row:
                    conn.rollback()
                    return None

                task = self._row_to_task(row)

                # Mark as in_progress with timestamp
                conn.execute(
                    "UPDATE tasks SET status = 'in_progress', started_at = ? WHERE id = ?",
                    (datetime.now(timezone.utc).isoformat(), task.id)
                )

                conn.commit()
                task.status = "in_progress"
                task.started_at = datetime.now(timezone.utc)
                return task

            except Exception:
                conn.rollback()
                raise

    def get_pending(self, limit: int | None = None) -> list[Task]:
        """Get all pending tasks."""
        with self._connect() as conn:
            query = "SELECT * FROM tasks WHERE status = 'pending' ORDER BY created_at ASC"
            if limit is not None:
                query += f" LIMIT {limit}"
            cur = conn.execute(query)
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_in_progress(self) -> list[Task]:
        """Get all in-progress tasks, oldest first."""
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT * FROM tasks WHERE status = 'in_progress' ORDER BY started_at ASC, created_at ASC"
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_history(
        self,
        limit: int | None = 10,
        status: str | None = None,
        task_type: str | None = None,
        since: "datetime | None" = None,
        until: "datetime | None" = None,
    ) -> list[Task]:
        """Get completed/failed tasks, most recent first.

        Args:
            limit: Maximum number of tasks to return (None for all)
            status: Filter by specific status (e.g., 'completed', 'failed', 'unmerged')
                   If None, returns all completed/failed/unmerged tasks
            task_type: Filter by specific task_type (e.g., 'task', 'explore', 'plan', 'implement', 'review', 'improve')
                      If None, returns all task types
            since: If specified, only return tasks where completed_at >= since
                   (falls back to created_at when completed_at is NULL)
            until: If specified, only return tasks where completed_at <= until
                   (falls back to created_at when completed_at is NULL)
        """
        with self._connect() as conn:
            # Build WHERE clause based on status and task_type filters
            where_clauses = []
            params = []

            if status:
                where_clauses.append("status = ?")
                params.append(status)
            else:
                where_clauses.append("status IN ('completed', 'failed', 'unmerged', 'dropped')")

            if task_type:
                where_clauses.append("task_type = ?")
                params.append(task_type)

            if since is not None:
                since_str = since.isoformat()
                where_clauses.append(
                    "(completed_at >= ? OR (completed_at IS NULL AND created_at >= ?))"
                )
                params.extend([since_str, since_str])

            if until is not None:
                until_str = until.isoformat()
                where_clauses.append(
                    "(completed_at <= ? OR (completed_at IS NULL AND created_at <= ?))"
                )
                params.extend([until_str, until_str])

            where_clause = "WHERE " + " AND ".join(where_clauses)

            # Add LIMIT clause if specified
            if limit is None:
                query = f"""
                    SELECT * FROM tasks
                    {where_clause}
                    ORDER BY completed_at DESC, id DESC
                """
                cur = conn.execute(query, params)
            else:
                query = f"""
                    SELECT * FROM tasks
                    {where_clause}
                    ORDER BY completed_at DESC, id DESC
                    LIMIT ?
                """
                params.append(str(limit))
                cur = conn.execute(query, params)

            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_based_on_children(self, task_id: int) -> list[Task]:
        """Return tasks where based_on = task_id (direct lineage descendants)."""
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT * FROM tasks WHERE based_on = ? ORDER BY id ASC",
                (task_id,),
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_resumable_failed_tasks(self) -> list[Task]:
        """Return failed tasks that can be auto-resumed.

        A task is resumable if:
        - status = 'failed'
        - failure_reason IN ('MAX_STEPS', 'MAX_TURNS')
        - session_id IS NOT NULL
        """
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE status = 'failed'
                AND failure_reason IN ('MAX_STEPS', 'MAX_TURNS')
                AND session_id IS NOT NULL
                ORDER BY completed_at DESC, id DESC
                """
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def count_resume_chain_depth(self, task_id: int) -> int:
        """Count consecutive failed ancestors with resumable failure reasons.

        Walks the based_on chain upward from task_id's parent, counting how many
        consecutive failed ancestors have failure_reason in ('MAX_STEPS', 'MAX_TURNS').
        This tells us how many times we've already tried resuming (not counting task_id itself).

        Examples:
          - Task #1 failed MAX_STEPS, based_on=None → depth=0 (no prior attempts)
          - Task #2 failed MAX_STEPS, based_on=#1 (also MAX_STEPS) → depth=1 (one prior attempt)
          - Task #3 failed MAX_STEPS, based_on=#2 → depth=2 (two prior attempts)
        """
        seen_ids: set[int] = set()
        seen_ids.add(task_id)
        # Start from the parent (based_on of task_id)
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT based_on FROM tasks WHERE id = ?",
                (task_id,),
            )
            row = cur.fetchone()
            if row is None:
                return 0
            current_id: int | None = row["based_on"]

            depth = 0
            while current_id is not None:
                if current_id in seen_ids:
                    break  # Cycle detected
                seen_ids.add(current_id)
                cur = conn.execute(
                    "SELECT based_on, status, failure_reason FROM tasks WHERE id = ?",
                    (current_id,),
                )
                row = cur.fetchone()
                if row is None:
                    break
                based_on, status, failure_reason = row["based_on"], row["status"], row["failure_reason"]
                if status == "failed" and failure_reason in ("MAX_STEPS", "MAX_TURNS"):
                    depth += 1
                    current_id = based_on
                else:
                    break
        return depth

    def get_recent_completed(self, limit: int = 15) -> list[Task]:
        """Get recent completed tasks, most recent first. Excludes internal learn tasks."""
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE status = 'completed'
                  AND task_type != 'learn'
                ORDER BY completed_at DESC, id DESC
                LIMIT ?
                """,
                (limit,),
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_unmerged(self) -> list[Task]:
        """Get tasks with unmerged code (merge_status = 'unmerged').

        Excludes improve tasks that have a parent (based_on) since they
        use same_branch=True and commit to the implementation task's branch.
        Standalone improve tasks with their own branch are included.
        """
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE merge_status = 'unmerged'
                AND (
                    task_type != 'improve'
                    OR based_on IS NULL
                )
                ORDER BY completed_at DESC
                """
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def set_merge_status(self, task_id: int, merge_status: str | None) -> None:
        """Set the merge_status for a task."""
        with self._connect() as conn:
            conn.execute(
                "UPDATE tasks SET merge_status = ? WHERE id = ?",
                (merge_status, task_id),
            )

    def clear_review_state(self, task_id: int) -> None:
        """Clear the review state on an implementation task.

        Called when an improve task completes, to indicate that the previous
        review's feedback has been addressed. Sets review_cleared_at to now.

        Note: This is called whenever an improve task completes with commits.
        It cannot verify whether the improve task actually addressed the review
        feedback in a meaningful way — it only records that an improve task ran.

        If task_id does not exist, this is a no-op (no error is raised).
        """
        now = datetime.now(timezone.utc).isoformat()
        with self._connect() as conn:
            conn.execute(
                "UPDATE tasks SET review_cleared_at = ? WHERE id = ?",
                (now, task_id),
            )

    def set_log_schema_version(self, task_id: int, version: int) -> None:
        """Set the persisted log schema marker for a task/run."""
        with self._connect() as conn:
            conn.execute(
                "UPDATE tasks SET log_schema_version = ? WHERE id = ?",
                (version, task_id),
            )

    # === Cycle orchestration APIs (v18) ===

    def _row_to_task_cycle(self, row: sqlite3.Row) -> "TaskCycle":
        """Convert a database row to a TaskCycle."""
        return TaskCycle(
            id=row["id"],
            implementation_task_id=row["implementation_task_id"],
            status=row["status"],
            max_iterations=row["max_iterations"],
            started_at=datetime.fromisoformat(row["started_at"]),
            ended_at=datetime.fromisoformat(row["ended_at"]) if row["ended_at"] else None,
            stop_reason=row["stop_reason"],
        )

    def _row_to_task_cycle_iteration(self, row: sqlite3.Row) -> "TaskCycleIteration":
        """Convert a database row to a TaskCycleIteration."""
        return TaskCycleIteration(
            id=row["id"],
            cycle_id=row["cycle_id"],
            iteration_index=row["iteration_index"],
            review_task_id=row["review_task_id"],
            review_verdict=row["review_verdict"],
            improve_task_id=row["improve_task_id"],
            state=row["state"],
            started_at=datetime.fromisoformat(row["started_at"]),
            ended_at=datetime.fromisoformat(row["ended_at"]) if row["ended_at"] else None,
        )

    def start_cycle(self, impl_task_id: int, max_iterations: int = 3) -> "TaskCycle":
        """Start a new cycle for an implementation task.

        Raises ValueError if there is already an active cycle for this task.
        """
        now = datetime.now(timezone.utc).isoformat()
        with self._connect() as conn:
            # Enforce uniqueness: only one active cycle per implementation
            cur = conn.execute(
                "SELECT id FROM task_cycles WHERE implementation_task_id = ? AND status = 'active'",
                (impl_task_id,),
            )
            existing = cur.fetchone()
            if existing:
                raise ValueError(
                    f"Task #{impl_task_id} already has an active cycle (#{existing['id']}). "
                    "Use --continue to resume it."
                )
            cur = conn.execute(
                """
                INSERT INTO task_cycles (implementation_task_id, status, max_iterations, started_at)
                VALUES (?, 'active', ?, ?)
                """,
                (impl_task_id, max_iterations, now),
            )
            cycle_id = cur.lastrowid
            assert cycle_id is not None
            cur = conn.execute("SELECT * FROM task_cycles WHERE id = ?", (cycle_id,))
            row = cur.fetchone()
            assert row is not None
            return self._row_to_task_cycle(row)

    def get_active_cycle_for_impl(self, impl_task_id: int) -> "TaskCycle | None":
        """Get the active cycle for an implementation task, if any."""
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT * FROM task_cycles WHERE implementation_task_id = ? AND status = 'active'",
                (impl_task_id,),
            )
            row = cur.fetchone()
            return self._row_to_task_cycle(row) if row else None

    def get_cycles_for_impl(self, impl_task_id: int) -> "list[TaskCycle]":
        """Get all cycles for an implementation task, newest first."""
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT * FROM task_cycles WHERE implementation_task_id = ? ORDER BY id DESC",
                (impl_task_id,),
            )
            return [self._row_to_task_cycle(row) for row in cur.fetchall()]

    def get_all_closed_cycles(self) -> "list[TaskCycle]":
        """Get all closed (non-active) cycles, newest first."""
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT * FROM task_cycles WHERE status != 'active' ORDER BY id DESC",
            )
            return [self._row_to_task_cycle(row) for row in cur.fetchall()]

    def append_cycle_iteration(self, cycle_id: int, iteration_index: int) -> "TaskCycleIteration":
        """Create a new iteration record for a cycle."""
        now = datetime.now(timezone.utc).isoformat()
        with self._connect() as conn:
            cur = conn.execute(
                """
                INSERT INTO task_cycle_iterations (cycle_id, iteration_index, state, started_at)
                VALUES (?, ?, 'review_created', ?)
                """,
                (cycle_id, iteration_index, now),
            )
            iter_id = cur.lastrowid
            assert iter_id is not None
            cur = conn.execute("SELECT * FROM task_cycle_iterations WHERE id = ?", (iter_id,))
            row = cur.fetchone()
            assert row is not None
            return self._row_to_task_cycle_iteration(row)

    def update_cycle_iteration(
        self,
        iteration_id: int,
        *,
        review_task_id: int | None = None,
        review_verdict: str | None = None,
        improve_task_id: int | None = None,
        state: str | None = None,
        ended_at: datetime | None = None,
    ) -> None:
        """Update fields on a cycle iteration row."""
        with self._connect() as conn:
            updates = []
            params: list[Any] = []
            if review_task_id is not None:
                updates.append("review_task_id = ?")
                params.append(review_task_id)
            if review_verdict is not None:
                updates.append("review_verdict = ?")
                params.append(review_verdict)
            if improve_task_id is not None:
                updates.append("improve_task_id = ?")
                params.append(improve_task_id)
            if state is not None:
                updates.append("state = ?")
                params.append(state)
            if ended_at is not None:
                updates.append("ended_at = ?")
                params.append(ended_at.isoformat())
            if not updates:
                return
            params.append(iteration_id)
            conn.execute(
                f"UPDATE task_cycle_iterations SET {', '.join(updates)} WHERE id = ?",
                params,
            )

    def get_cycle_iterations(self, cycle_id: int) -> "list[TaskCycleIteration]":
        """Get all iterations for a cycle, ordered by iteration_index."""
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT * FROM task_cycle_iterations WHERE cycle_id = ? ORDER BY iteration_index ASC",
                (cycle_id,),
            )
            return [self._row_to_task_cycle_iteration(row) for row in cur.fetchall()]

    def close_cycle(self, cycle_id: int, status: str, stop_reason: str) -> None:
        """Close a cycle with the given status and stop reason."""
        now = datetime.now(timezone.utc).isoformat()
        with self._connect() as conn:
            conn.execute(
                "UPDATE task_cycles SET status = ?, stop_reason = ?, ended_at = ? WHERE id = ?",
                (status, stop_reason, now, cycle_id),
            )

    def get_cycle_aggregate_stats(self) -> dict:
        """Get aggregate cycle stats across all closed approved cycles.

        Returns a dict with percentile distributions for:
        - improves_before_approval (improve task count before first approval in approved cycles)
        - reviews_per_cycle
        - improves_per_cycle
        - cycle_duration_seconds
        """
        cycles = self.get_all_closed_cycles()
        if not cycles:
            return {
                "total_cycles": 0,
                "approved_cycles": 0,
                "improves_before_approval": None,
                "reviews_per_cycle": None,
                "improves_per_cycle": None,
                "cycle_duration_seconds": None,
            }

        approved = [c for c in cycles if c.status == "approved"]
        improves_before_approval_vals: list[float] = []
        reviews_vals: list[float] = []
        improves_vals: list[float] = []
        duration_vals: list[float] = []

        for cycle in cycles:
            iters = self.get_cycle_iterations(cycle.id)
            reviews_vals.append(sum(1 for it in iters if it.review_task_id is not None))
            improves_vals.append(sum(1 for it in iters if it.improve_task_id is not None))
            if cycle.ended_at and cycle.started_at:
                duration_vals.append((cycle.ended_at - cycle.started_at).total_seconds())

        for cycle in approved:
            iters = self.get_cycle_iterations(cycle.id)
            improves_before_approval_vals.append(sum(1 for it in iters if it.improve_task_id is not None))

        return {
            "total_cycles": len(cycles),
            "approved_cycles": len(approved),
            "improves_before_approval": _compute_percentiles(improves_before_approval_vals) if improves_before_approval_vals else None,
            "reviews_per_cycle": _compute_percentiles(reviews_vals) if reviews_vals else None,
            "improves_per_cycle": _compute_percentiles(improves_vals) if improves_vals else None,
            "cycle_duration_seconds": _compute_percentiles(duration_vals) if duration_vals else None,
        }

    # === Run step/substep persistence ===

    def emit_step(
        self,
        run_id: int,
        message: str | None,
        *,
        provider: str,
        message_role: str = "assistant",
        started_at: datetime | None = None,
        legacy_turn_id: str | None = None,
        legacy_event_id: str | None = None,
    ) -> StepRef:
        """Create and persist a top-level message step for a run."""
        timestamp = (started_at or datetime.now(timezone.utc)).isoformat()
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT COALESCE(MAX(step_index), 0) + 1 AS next_step FROM run_steps WHERE run_id = ?",
                (run_id,),
            )
            next_step = int(cur.fetchone()["next_step"])
            step_label = f"S{next_step}"
            cur = conn.execute(
                """
                INSERT INTO run_steps (
                    run_id, step_index, step_id, provider, message_role,
                    message_text, started_at, legacy_turn_id, legacy_event_id
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    run_id,
                    next_step,
                    step_label,
                    provider,
                    message_role,
                    message,
                    timestamp,
                    legacy_turn_id,
                    legacy_event_id,
                ),
            )
            step_row_id = cur.lastrowid
            assert step_row_id is not None
            return StepRef(id=step_row_id, run_id=run_id, step_index=next_step, step_id=step_label)

    def emit_substep(
        self,
        step_ref: StepRef,
        substep_type: str,
        payload: Any,
        *,
        source: str,
        call_id: str | None = None,
        timestamp: datetime | None = None,
        legacy_turn_id: str | None = None,
        legacy_event_id: str | None = None,
    ) -> RunSubstep:
        """Append a substep/tool event under an existing step."""
        ts = (timestamp or datetime.now(timezone.utc)).isoformat()
        payload_json = json.dumps(payload)
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT run_id, step_index, step_id FROM run_steps WHERE id = ?",
                (step_ref.id,),
            )
            row = cur.fetchone()
            if row is None:
                raise ValueError(f"Unknown step reference: {step_ref.id}")
            if row["run_id"] != step_ref.run_id:
                raise ValueError(
                    f"Step reference run mismatch: step run_id={row['run_id']}, ref run_id={step_ref.run_id}"
                )
            if row["step_index"] != step_ref.step_index:
                raise ValueError(
                    f"Step reference index mismatch: step step_index={row['step_index']}, ref step_index={step_ref.step_index}"
                )
            if row["step_id"] != step_ref.step_id:
                raise ValueError(
                    f"Step reference label mismatch: step step_id={row['step_id']}, ref step_id={step_ref.step_id}"
                )

            cur = conn.execute(
                "SELECT COALESCE(MAX(substep_index), 0) + 1 AS next_substep FROM run_substeps WHERE step_id = ?",
                (step_ref.id,),
            )
            next_substep = int(cur.fetchone()["next_substep"])
            substep_label = f"{step_ref.step_id}.{next_substep}"
            cur = conn.execute(
                """
                INSERT INTO run_substeps (
                    run_id, step_id, substep_index, substep_id, type, source, call_id,
                    payload_json, timestamp, legacy_turn_id, legacy_event_id
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    step_ref.run_id,
                    step_ref.id,
                    next_substep,
                    substep_label,
                    substep_type,
                    source,
                    call_id,
                    payload_json,
                    ts,
                    legacy_turn_id,
                    legacy_event_id,
                ),
            )
            substep_row_id = cur.lastrowid
            assert substep_row_id is not None
            row = conn.execute("SELECT * FROM run_substeps WHERE id = ?", (substep_row_id,)).fetchone()
            assert row is not None
            return self._row_to_run_substep(row)

    def finalize_step(
        self,
        step_ref: StepRef,
        outcome: str,
        summary: str | None = None,
        *,
        completed_at: datetime | None = None,
    ) -> None:
        """Mark a step complete and persist final outcome metadata."""
        ts = (completed_at or datetime.now(timezone.utc)).isoformat()
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT run_id, step_index, step_id FROM run_steps WHERE id = ?",
                (step_ref.id,),
            )
            row = cur.fetchone()
            if row is None:
                raise ValueError(f"Unknown step reference: {step_ref.id}")
            if row["run_id"] != step_ref.run_id:
                raise ValueError(
                    f"Step reference run mismatch: step run_id={row['run_id']}, ref run_id={step_ref.run_id}"
                )
            if row["step_index"] != step_ref.step_index:
                raise ValueError(
                    f"Step reference index mismatch: step step_index={row['step_index']}, ref step_index={step_ref.step_index}"
                )
            if row["step_id"] != step_ref.step_id:
                raise ValueError(
                    f"Step reference label mismatch: step step_id={row['step_id']}, ref step_id={step_ref.step_id}"
                )

            cur = conn.execute(
                """
                UPDATE run_steps
                SET completed_at = ?, outcome = ?, summary = ?
                WHERE id = ?
                """,
                (ts, outcome, summary, step_ref.id),
            )
            if cur.rowcount == 0:
                raise ValueError(f"Unknown step reference: {step_ref.id}")

    def count_steps(self, run_id: int) -> int:
        """Count the number of run_steps rows for a given run_id."""
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT COUNT(*) AS cnt FROM run_steps WHERE run_id = ?",
                (run_id,),
            )
            row = cur.fetchone()
            return int(row["cnt"]) if row else 0

    def get_run_steps(self, run_id: int) -> list[RunStep]:
        """Get all stored run steps for a run, ordered by step index."""
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT * FROM run_steps WHERE run_id = ? ORDER BY step_index ASC",
                (run_id,),
            )
            return [self._row_to_run_step(row) for row in cur.fetchall()]

    def get_run_substeps(self, step_ref: StepRef) -> list[RunSubstep]:
        """Get all stored substeps for a step, ordered by substep index."""
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT run_id, step_index, step_id FROM run_steps WHERE id = ?",
                (step_ref.id,),
            )
            row = cur.fetchone()
            if row is None:
                raise ValueError(f"Unknown step reference: {step_ref.id}")
            if row["run_id"] != step_ref.run_id:
                raise ValueError(
                    f"Step reference run mismatch: step run_id={row['run_id']}, ref run_id={step_ref.run_id}"
                )
            if row["step_index"] != step_ref.step_index:
                raise ValueError(
                    f"Step reference index mismatch: step step_index={row['step_index']}, ref step_index={step_ref.step_index}"
                )
            if row["step_id"] != step_ref.step_id:
                raise ValueError(
                    f"Step reference label mismatch: step step_id={row['step_id']}, ref step_id={step_ref.step_id}"
                )

            cur = conn.execute(
                """
                SELECT * FROM run_substeps
                WHERE run_id = ? AND step_id = ?
                ORDER BY substep_index ASC
                """,
                (step_ref.run_id, step_ref.id),
            )
            return [self._row_to_run_substep(row) for row in cur.fetchall()]

    def get_all(self) -> list[Task]:
        """Get all tasks."""
        with self._connect() as conn:
            cur = conn.execute("SELECT * FROM tasks ORDER BY created_at DESC")
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_impl_based_on_ids(self) -> set[int]:
        """Return the set of plan IDs that already have an implement task.

        Checks both based_on and depends_on, since implement tasks may
        reference their plan via either column.
        """
        with self._connect() as conn:
            cur = conn.execute(
                "SELECT DISTINCT based_on FROM tasks"
                " WHERE task_type = 'implement' AND based_on IS NOT NULL"
                " UNION"
                " SELECT DISTINCT depends_on FROM tasks"
                " WHERE task_type = 'implement' AND depends_on IS NOT NULL"
            )
            return {row[0] for row in cur.fetchall()}

    def get_reviews_for_task(self, task_id: int) -> list[Task]:
        """Get all review tasks that depend on the given task, ordered by completed_at DESC.

        Reviews are ordered by completed_at so that the most recently completed
        review is first (reviews[0]). Incomplete reviews (completed_at IS NULL) sort
        last. This ensures the staleness check in cmd_unmerged compares against the
        review that completed most recently, not merely the one created most recently.
        """
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE task_type = 'review' AND depends_on = ?
                ORDER BY completed_at DESC NULLS LAST
                """,
                (task_id,),
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_unlinked_reviews_for_slug(self, slug: str) -> list[Task]:
        """Get completed review tasks not linked via depends_on, matched by slug.

        This is a fallback for review tasks created manually (e.g., prompt starts
        with "review <slug>") without an explicit depends_on relationship.
        """
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE task_type = 'review'
                  AND status = 'completed'
                  AND depends_on IS NULL
                  AND (
                    task_id LIKE ?
                    OR prompt LIKE ?
                  )
                ORDER BY completed_at DESC NULLS LAST
                """,
                (f"%review-{slug}%", f"review {slug}%"),
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_improve_tasks_for(self, impl_task_id: int, review_task_id: int) -> list[Task]:
        """Get improve tasks that match the given implementation and review task IDs."""
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE task_type = 'improve' AND based_on = ? AND depends_on = ?
                ORDER BY created_at DESC
                """,
                (impl_task_id, review_task_id),
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_improve_tasks_by_root(self, root_task_id: int) -> list[Task]:
        """Get all improve tasks whose based_on points to root_task_id."""
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE task_type = 'improve' AND based_on = ?
                ORDER BY created_at DESC
                """,
                (root_task_id,),
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_impl_tasks_by_depends_on_or_based_on(self, task_id: int) -> list[Task]:
        """Get implement tasks that depend on or are based on a given task."""
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE task_type = 'implement' AND (based_on = ? OR depends_on = ?)
                ORDER BY created_at ASC
                """,
                (task_id, task_id),
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_stats(self) -> dict:
        """Get aggregate statistics."""
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT
                    COUNT(*) FILTER (WHERE status = 'completed') as completed,
                    COUNT(*) FILTER (WHERE status = 'failed') as failed,
                    COUNT(*) FILTER (WHERE status = 'pending') as pending,
                    COUNT(*) FILTER (WHERE status = 'dropped') as dropped,
                    SUM(cost_usd) as total_cost,
                    SUM(duration_seconds) as total_duration,
                    SUM(COALESCE(num_steps_reported, num_steps_computed, num_turns_reported)) as total_steps,
                    SUM(num_turns_reported) as total_turns,
                    SUM(input_tokens) as total_input_tokens,
                    SUM(output_tokens) as total_output_tokens
                FROM tasks
                """
            )
            row = cur.fetchone()
            return {
                "completed": row["completed"] or 0,
                "failed": row["failed"] or 0,
                "pending": row["pending"] or 0,
                "dropped": row["dropped"] or 0,
                "total_cost": row["total_cost"] or 0,
                "total_duration": row["total_duration"] or 0,
                "total_steps": row["total_steps"] or 0,
                "total_turns": row["total_turns"] or 0,
                "total_input_tokens": row["total_input_tokens"] or 0,
                "total_output_tokens": row["total_output_tokens"] or 0,
            }

    def search(self, query: str) -> list[Task]:
        """Search tasks by prompt content."""
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE prompt LIKE ?
                ORDER BY created_at DESC
                """,
                (f"%{query}%",),
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def get_groups(self) -> dict[str, dict[str, int]]:
        """Get all groups with task counts by status.

        Returns:
            Dict mapping group name to dict of status counts.
            Example: {"tarantino-v2": {"pending": 1, "completed": 2}, ...}
        """
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT "group", status, COUNT(*) as count
                FROM tasks
                WHERE "group" IS NOT NULL
                GROUP BY "group", status
                """
            )
            groups: dict[str, dict[str, int]] = {}
            for row in cur.fetchall():
                group_name = row["group"]
                status = row["status"]
                count = row["count"]
                if group_name not in groups:
                    groups[group_name] = {}
                groups[group_name][status] = count
            return groups

    def get_by_group(self, group: str) -> list[Task]:
        """Get all tasks in a group, ordered by creation time."""
        with self._connect() as conn:
            cur = conn.execute(
                """
                SELECT * FROM tasks
                WHERE "group" = ?
                ORDER BY created_at ASC
                """,
                (group,)
            )
            return [self._row_to_task(row) for row in cur.fetchall()]

    def _find_successful_retry(self, task_id: int) -> bool:
        """Check if any completed task exists in the retry chain rooted at task_id.

        Follows based_on links forward (task_id → retries → retries of retries)
        and returns True if any task in that tree has status='completed'.
        No data is mutated; this is a read-only query-time check.

        NOTE: 'dropped' nodes in the retry chain are intentionally not treated as
        successful retries. A dropped task means the work was deliberately abandoned,
        not completed. Only status='completed' unblocks a dependency chain.
        """
        visited: set[int] = set()
        queue = [task_id]
        while queue:
            current_id = queue.pop(0)
            if current_id in visited:
                continue
            visited.add(current_id)
            with self._connect() as conn:
                cur = conn.execute(
                    "SELECT id, status FROM tasks WHERE based_on = ?",
                    (current_id,),
                )
                for row in cur.fetchall():
                    if row["status"] == "completed":
                        return True
                    queue.append(row["id"])
        return False

    def is_task_blocked(self, task: Task) -> tuple[bool, int | None, str | None]:
        """Check if a task is blocked by an incomplete dependency.

        When the direct dependency has failed, follows the retry chain (via
        based_on) to see if a subsequent retry succeeded.  If so, the task is
        treated as unblocked.

        Returns:
            Tuple of (is_blocked, blocking_task_id, blocking_task_status)
        """
        if task.depends_on is None:
            return (False, None, None)

        dep = self.get(task.depends_on)
        if dep is None:
            return (False, None, None)

        if dep.status == "completed":
            return (False, None, None)

        if dep.status == "failed" and dep.id is not None and self._find_successful_retry(dep.id):
            return (False, None, None)

        return (True, dep.id, dep.status)

    def count_blocked_tasks(self) -> int:
        """Count pending tasks that are blocked by dependencies.

        A task is unblocked (and therefore not counted) if its dependency is
        completed OR if the dependency is failed but a completed retry exists
        anywhere in the based_on chain.

        NOTE: This SQL intentionally mirrors the Python logic in is_task_blocked():
        both treat only 'completed' (or 'failed' with a successful retry) as
        unblocking. A 'dropped' dependency is therefore counted as blocking here,
        consistent with is_task_blocked() returning (True, ...) for dropped deps.
        If the semantics of dropped-unblocks-dependents ever change, both sites
        must be updated together.
        """
        with self._connect() as conn:
            cur = conn.execute(
                """
                WITH RECURSIVE successful_ancestors(id) AS (
                    SELECT id FROM tasks WHERE status = 'completed'
                    UNION ALL
                    SELECT t2.based_on FROM tasks t2
                    JOIN successful_ancestors sa ON t2.id = sa.id
                    WHERE t2.based_on IS NOT NULL
                )
                SELECT COUNT(*) as count FROM tasks t
                WHERE t.status = 'pending'
                AND t.depends_on IS NOT NULL
                AND t.depends_on NOT IN (SELECT id FROM successful_ancestors)
                """
            )
            row = cur.fetchone()
            return row["count"] if row else 0

    # === Status transitions (TaskStore protocol) ===

    def mark_in_progress(self, task: Task) -> None:
        """Mark a task as in progress."""
        task.status = "in_progress"
        task.started_at = datetime.now(timezone.utc)
        self.update(task)

    def mark_completed(
        self,
        task: Task,
        branch: str | None = None,
        log_file: str | None = None,
        report_file: str | None = None,
        output_content: str | None = None,
        has_commits: bool = False,
        stats: TaskStats | None = None,
        diff_files_changed: int | None = None,
        diff_lines_added: int | None = None,
        diff_lines_removed: int | None = None,
    ) -> None:
        """Mark a task as completed."""
        task.status = "completed"
        task.completed_at = datetime.now(timezone.utc)
        task.has_commits = has_commits
        if has_commits:
            task.merge_status = "unmerged"
        if branch:
            task.branch = branch
        if log_file:
            task.log_file = log_file
        if report_file:
            task.report_file = report_file
        if output_content is not None:
            task.output_content = output_content
        if stats:
            task.duration_seconds = stats.duration_seconds
            task.num_steps_reported = stats.num_steps_reported
            task.num_steps_computed = stats.num_steps_computed
            task.num_turns_reported = stats.num_turns_reported
            task.num_turns_computed = stats.num_turns_computed
            task.cost_usd = stats.cost_usd
            task.input_tokens = stats.input_tokens
            task.output_tokens = stats.output_tokens
        task.diff_files_changed = diff_files_changed
        task.diff_lines_added = diff_lines_added
        task.diff_lines_removed = diff_lines_removed
        self.update(task)

    def update_diff_stats(
        self,
        task_id: int,
        files_changed: int | None,
        lines_added: int | None,
        lines_removed: int | None,
    ) -> None:
        """Update only the diff stats columns for a task."""
        with self._connect() as conn:
            conn.execute(
                """
                UPDATE tasks SET
                    diff_files_changed = ?,
                    diff_lines_added = ?,
                    diff_lines_removed = ?
                WHERE id = ?
                """,
                (files_changed, lines_added, lines_removed, task_id),
            )

    def mark_failed(
        self,
        task: Task,
        log_file: str | None = None,
        has_commits: bool = False,
        stats: TaskStats | None = None,
        branch: str | None = None,
        failure_reason: str | None = None,
    ) -> None:
        """Mark a task as failed."""
        task.status = "failed"
        task.completed_at = datetime.now(timezone.utc)
        task.has_commits = has_commits
        if log_file:
            task.log_file = log_file
        if branch:
            task.branch = branch
        if stats:
            task.duration_seconds = stats.duration_seconds
            task.num_steps_reported = stats.num_steps_reported
            task.num_steps_computed = stats.num_steps_computed
            task.num_turns_reported = stats.num_turns_reported
            task.num_turns_computed = stats.num_turns_computed
            task.cost_usd = stats.cost_usd
            task.input_tokens = stats.input_tokens
            task.output_tokens = stats.output_tokens
        task.failure_reason = failure_reason if failure_reason is not None else "UNKNOWN"
        self.update(task)

    def mark_unmerged(
        self,
        task: Task,
        branch: str | None = None,
        log_file: str | None = None,
        has_commits: bool = False,
        stats: TaskStats | None = None,
    ) -> None:
        """Mark a task as unmerged."""
        task.status = "unmerged"
        task.completed_at = datetime.now(timezone.utc)
        task.has_commits = has_commits
        if branch:
            task.branch = branch
        if log_file:
            task.log_file = log_file
        if stats:
            task.duration_seconds = stats.duration_seconds
            task.num_steps_reported = stats.num_steps_reported
            task.num_steps_computed = stats.num_steps_computed
            task.num_turns_reported = stats.num_turns_reported
            task.num_turns_computed = stats.num_turns_computed
            task.cost_usd = stats.cost_usd
            task.input_tokens = stats.input_tokens
            task.output_tokens = stats.output_tokens
        self.update(task)


# === Merge status migration ===

def needs_merge_status_migration(store: "SqliteTaskStore") -> bool:
    """Check if any tasks need merge_status backfilled."""
    with store._connect() as conn:
        cur = conn.execute(
            "SELECT COUNT(*) FROM tasks WHERE merge_status IS NULL AND has_commits = 1"
        )
        return cur.fetchone()[0] > 0


def migrate_merge_status(store: "SqliteTaskStore", git: "object") -> None:
    """Infer merge_status for existing tasks based on current git state.

    This is used to backfill merge_status for tasks created before the
    merge_status column was added.

    Args:
        store: The task store
        git: A Git instance for the project
    """
    from .git import Git as GitClass
    assert isinstance(git, GitClass)

    with store._connect() as conn:
        cur = conn.execute(
            "SELECT id, has_commits, branch FROM tasks WHERE merge_status IS NULL"
        )
        rows = cur.fetchall()

    default_branch = git.default_branch()

    for row in rows:
        task_id = row["id"]
        has_commits = row["has_commits"]
        branch = row["branch"]

        if not has_commits:
            merge_status = None
        elif not branch:
            merge_status = None
        elif not git.branch_exists(branch):
            # Branch deleted - assume merged
            merge_status = "merged"
        else:
            try:
                # Check if branch is an ancestor of main (i.e., fully merged)
                result = git._run(
                    "merge-base", "--is-ancestor", branch, default_branch, check=False
                )
                if result.returncode == 0:
                    merge_status = "merged"
                elif git.is_merged(branch, default_branch):
                    # No diff (squash merged or equivalent content)
                    merge_status = "merged"
                else:
                    merge_status = "unmerged"
            except Exception:
                logger.warning(
                    "Could not determine merge status for task_id=%s branch=%s against %s; defaulting to 'unmerged'",
                    task_id,
                    branch,
                    default_branch,
                    exc_info=True,
                )
                merge_status = "unmerged"

        store.set_merge_status(task_id, merge_status)


# === Editor support ===

TASK_TEMPLATE_HEADER = """# Enter your task prompt below.
# Lines starting with # are comments and will be ignored.
# Save and close the editor when done.
#
"""


def edit_prompt(
    initial_content: str = "",
    task_type: str = "task",
    based_on: int | None = None,
    based_on_slug: str | None = None,
    spec: str | None = None,
    group: str | None = None,
    depends_on: int | None = None,
    create_review: bool = False,
    same_branch: bool = False,
    model: str | None = None,
    provider: str | None = None,
) -> str | None:
    """Open $EDITOR for the user to enter/edit a prompt.

    Returns the prompt text, or None if cancelled/empty.
    """
    editor = os.environ.get("EDITOR", "vim")

    # Build options section
    options = [f"# Type: {task_type}"]
    if based_on:
        options.append(f"# Based on: #{based_on}")
    if depends_on:
        options.append(f"# Depends on: #{depends_on}")
    if group:
        options.append(f"# Group: {group}")
    if spec:
        options.append(f"# Spec: {spec}")
    if create_review:
        options.append("# Create review: yes")
    if same_branch:
        options.append("# Same branch: yes")
    if model:
        options.append(f"# Model: {model}")
    if provider:
        options.append(f"# Provider: {provider}")

    template = TASK_TEMPLATE_HEADER + "\n".join(options) + "\n"

    # Provide default prompt for implement tasks with based_on
    # This makes the slug unique by including the task ID
    if not initial_content and task_type == "implement" and based_on:
        if based_on_slug:
            initial_content = f"Implement plan from task #{based_on}: {based_on_slug}"
        else:
            initial_content = f"Implement plan from task #{based_on}"

    content = template + "\n" + initial_content

    with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
        f.write(content)
        tmp_path = f.name

    try:
        result = subprocess.run([editor, tmp_path])
        if result.returncode != 0:
            return None

        with open(tmp_path) as f:
            lines = f.readlines()

        # Strip comments and empty lines
        prompt_lines = [line for line in lines if not line.startswith("#")]
        prompt = "".join(prompt_lines).strip()

        return prompt if prompt else None
    finally:
        os.unlink(tmp_path)


def add_task_interactive(
    store: SqliteTaskStore,
    task_type: str = "task",
    based_on: int | None = None,
    spec: str | None = None,
    group: str | None = None,
    depends_on: int | None = None,
    create_review: bool = False,
    same_branch: bool = False,
    task_type_hint: str | None = None,
    model: str | None = None,
    provider: str | None = None,
    skip_learnings: bool = False,
) -> Task | None:
    """Interactively add a task using $EDITOR.

    Returns the created task, or None if cancelled.
    """
    # Look up slug from the based_on task's task_id (format: YYYYMMDD-slug)
    based_on_slug = None
    if based_on:
        based_on_task = store.get(based_on)
        if based_on_task and based_on_task.task_id:
            based_on_slug = based_on_task.task_id[9:]  # Skip YYYYMMDD-

    while True:
        prompt = edit_prompt(
            task_type=task_type,
            based_on=based_on,
            based_on_slug=based_on_slug,
            spec=spec,
            group=group,
            depends_on=depends_on,
            create_review=create_review,
            same_branch=same_branch,
            model=model,
            provider=provider,
        )

        if prompt is None:
            print("Task cancelled (empty prompt)")
            return None

        # Validate prompt
        errors = validate_prompt(prompt)

        if not errors:
            return store.add(
                prompt,
                task_type=task_type,
                based_on=based_on,
                group=group,
                depends_on=depends_on,
                spec=spec,
                create_review=create_review,
                same_branch=same_branch,
                task_type_hint=task_type_hint,
                model=model,
                provider=provider,
                skip_learnings=skip_learnings,
            )

        # Show errors and ask what to do
        print("Validation errors:")
        for error in errors:
            print(f"  - {error}")

        choice = input("\n(e)dit again, (q)uit? ").strip().lower()
        if choice == 'q':
            print("Task not created.")
            return None
        # Otherwise loop back to editor


def edit_task_interactive(store: SqliteTaskStore, task: Task) -> bool:
    """Interactively edit a task's prompt using $EDITOR.

    Returns True if edited successfully, False if cancelled.
    """
    while True:
        prompt = edit_prompt(
            initial_content=task.prompt,
            task_type=task.task_type,
            based_on=task.based_on,
            spec=task.spec,
            group=task.group,
            depends_on=task.depends_on,
            create_review=task.create_review,
            same_branch=task.same_branch,
            model=task.model,
            provider=task.provider,
        )

        if prompt is None:
            print("Edit cancelled")
            return False

        errors = validate_prompt(prompt)

        if not errors:
            task.prompt = prompt
            store.update(task)
            return True

        print("Validation errors:")
        for error in errors:
            print(f"  - {error}")

        choice = input("\n(e)dit again, (q)uit? ").strip().lower()
        if choice == 'q':
            print("Edit cancelled.")
            return False


# === Module-level convenience functions ===

def _default_store() -> "SqliteTaskStore":
    """Create a SqliteTaskStore using the default .gza/gza.db path relative to cwd."""
    return SqliteTaskStore(Path(".gza/gza.db"))


def _task_to_dict(task: "Task") -> dict:
    """Convert a Task to a JSON-serializable dict."""
    return {
        "id": task.id,
        "prompt": task.prompt,
        "status": task.status,
        "task_type": task.task_type,
        "task_id": task.task_id,
        "branch": task.branch,
        "log_file": task.log_file,
        "report_file": task.report_file,
        "based_on": task.based_on,
        "has_commits": task.has_commits,
        "duration_seconds": task.duration_seconds,
        "num_steps_reported": task.num_steps_reported,
        "num_steps_computed": task.num_steps_computed,
        "num_turns_reported": task.num_turns_reported,
        "num_turns_computed": task.num_turns_computed,
        "cost_usd": task.cost_usd,
        "input_tokens": task.input_tokens,
        "output_tokens": task.output_tokens,
        "created_at": task.created_at.isoformat() if task.created_at else None,
        "started_at": task.started_at.isoformat() if task.started_at else None,
        "completed_at": task.completed_at.isoformat() if task.completed_at else None,
        "group": task.group,
        "depends_on": task.depends_on,
        "spec": task.spec,
        "create_review": task.create_review,
        "same_branch": task.same_branch,
        "task_type_hint": task.task_type_hint,
        "output_content": task.output_content,
        "session_id": task.session_id,
        "pr_number": task.pr_number,
        "model": task.model,
        "provider": task.provider,
        "merge_status": task.merge_status,
        "failure_reason": task.failure_reason,
        "skip_learnings": task.skip_learnings,
        "diff_files_changed": task.diff_files_changed,
        "diff_lines_added": task.diff_lines_added,
        "diff_lines_removed": task.diff_lines_removed,
        "review_cleared_at": task.review_cleared_at.isoformat() if task.review_cleared_at else None,
        "log_schema_version": task.log_schema_version,
    }


def get_task(task_id: int) -> dict:
    """Get a task by ID as a JSON-serializable dict.

    Auto-discovers the DB at .gza/gza.db relative to cwd.

    Raises:
        KeyError: If task_id is not found.
    """
    store = _default_store()
    task = store.get(task_id)
    if task is None:
        raise KeyError(f"Task {task_id} not found")
    return _task_to_dict(task)


def get_task_log_path(task_id: int) -> str | None:
    """Get the log_file path for a task.

    Auto-discovers the DB at .gza/gza.db relative to cwd.
    Returns None if task not found or log_file is not set.
    """
    store = _default_store()
    task = store.get(task_id)
    if task is None:
        return None
    return task.log_file


def get_task_report_path(task_id: int) -> str | None:
    """Get the report_file path for a task.

    Auto-discovers the DB at .gza/gza.db relative to cwd.
    Returns None if task not found or report_file is not set.
    """
    store = _default_store()
    task = store.get(task_id)
    if task is None:
        return None
    return task.report_file


def get_baseline_stats(limit: int = 20) -> dict:
    """Get average stats from the last N completed tasks.

    Auto-discovers the DB at .gza/gza.db relative to cwd.

    Returns:
        Dict with keys: avg_steps, avg_turns, avg_duration, avg_cost
    """
    store = _default_store()
    with store._connect() as conn:
        cur = conn.execute(
            """
            SELECT
                round(avg(num_steps_reported), 1) as avg_steps,
                round(avg(num_turns_reported), 1) as avg_turns,
                round(avg(duration_seconds), 1) as avg_duration,
                round(avg(cost_usd), 4) as avg_cost
            FROM (
                SELECT * FROM tasks
                WHERE status = 'completed'
                ORDER BY completed_at DESC
                LIMIT ?
            )
            """,
            (limit,),
        )
        row = cur.fetchone()
        return {
            "avg_steps": row["avg_steps"],
            "avg_turns": row["avg_turns"],
            "avg_duration": row["avg_duration"],
            "avg_cost": row["avg_cost"],
        }


def validate_prompt(prompt: str) -> list[str]:
    """Validate a task prompt.

    Returns list of error messages (empty if valid).
    """
    errors = []

    if not prompt:
        errors.append("Prompt cannot be empty")
    elif len(prompt) < 10:
        errors.append("Prompt is too short (minimum 10 characters)")
    elif len(prompt) > 10000:
        errors.append("Prompt is too long (maximum 10000 characters)")

    return errors
