from __future__ import annotations

import json
import hashlib
import sqlite3
import subprocess
import time
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path

from fastapi.testclient import TestClient
import pytest

from overdrive.server.api import create_app
from overdrive.runtime.orchestrator import DefaultWorkerAdapter
from overdrive.runtime.domain.models import (
    AgentRecord,
    PlanRefineJob,
    PlanRevision,
    ReviewCycle,
    RunRecord,
    Task,
    TerminalSession,
    now_iso,
)
from overdrive.runtime.orchestrator.worker_adapter import StepResult
from overdrive.runtime.storage.bootstrap import ensure_state_root
from overdrive.runtime.storage.container import Container
from overdrive.runtime.storage.file_repos import (
    FileAgentRepository,
    FileConfigRepository,
    FileEventRepository,
    FilePlanRefineJobRepository,
    FilePlanRevisionRepository,
    FileReviewRepository,
    FileRunRepository,
    FileTaskRepository,
    FileTerminalSessionRepository,
)
from overdrive.runtime.storage.sqlite_db import SQLiteDB


def _patch_task_record(container: Container, task_id: str, fields: dict[str, object]) -> None:
    with container.transaction() as conn:
        row = conn.execute("SELECT payload FROM tasks WHERE id = ?", (task_id,)).fetchone()
        assert row is not None
        payload = json.loads(str(row["payload"]))
        assert isinstance(payload, dict)
        payload.update(fields)
        conn.execute(
            """
            UPDATE tasks
            SET status = ?,
                priority = ?,
                retry_count = ?,
                created_at = ?,
                updated_at = ?,
                pending_gate = ?,
                payload = ?
            WHERE id = ?
            """,
            (
                str(payload.get("status") or "queued"),
                str(payload.get("priority") or "P2"),
                int(payload.get("retry_count") or 0),
                str(payload.get("created_at") or now_iso()),
                str(payload.get("updated_at") or now_iso()),
                str(payload.get("pending_gate")) if payload.get("pending_gate") else None,
                json.dumps(payload, separators=(",", ":"), sort_keys=False),
                task_id,
            ),
        )


class _ClassifierWorkerAdapter(DefaultWorkerAdapter):
    def __init__(self, *, summary: str, status: str = "ok") -> None:
        super().__init__()
        self._summary = summary
        self._status = status

    def run_step(self, *, task: Task, step: str, attempt: int) -> StepResult:
        if step == "pipeline_classify":
            return StepResult(status=self._status, summary=self._summary)
        return super().run_step(task=task, step=step, attempt=attempt)


class _PrdImportWorkerAdapter(DefaultWorkerAdapter):
    """Deterministic PRD import adapter for tests without runtime scripted metadata."""

    def run_step(self, *, task: Task, step: str, attempt: int) -> StepResult:
        if step == "generate_tasks":
            parsed = task.metadata.get("parsed_prd") if isinstance(task.metadata, dict) else None
            candidates = parsed.get("task_candidates") if isinstance(parsed, dict) else None
            if not isinstance(candidates, list) or not candidates:
                lines = [line.strip() for line in str(task.description or "").splitlines()]
                fallback = [line[2:].strip() for line in lines if line.startswith("- ") and line[2:].strip()]
                candidates = [{"title": title, "priority": "P2"} for title in fallback]
            if isinstance(candidates, list):
                generated: list[dict[str, object]] = []
                prev: str | None = None
                for idx, item in enumerate(candidates, start=1):
                    if not isinstance(item, dict):
                        continue
                    ref_id = f"prd_{idx}"
                    generated.append(
                        {
                            "id": ref_id,
                            "title": str(item.get("title") or f"Imported PRD task {idx}"),
                            "task_type": "feature",
                            "priority": str(item.get("priority") or "P2"),
                            "depends_on": [prev] if prev else [],
                            "metadata": {
                                "generated_ref_id": ref_id,
                                "generated_depends_on": [prev] if prev else [],
                                "source_chunk_id": item.get("source_chunk_id"),
                                "source_section_path": item.get("source_section_path"),
                            },
                        }
                    )
                    prev = ref_id
                return StepResult(status="ok", generated_tasks=generated)
        return super().run_step(task=task, step=step, attempt=attempt)


def _create_plan_revision(client: TestClient, task_id: str, content: str, *, parent_revision_id: str | None = None) -> str:
    payload: dict[str, object] = {"content": content}
    if parent_revision_id:
        payload["parent_revision_id"] = parent_revision_id
    resp = client.post(f"/api/tasks/{task_id}/plan/revisions", json=payload)
    assert resp.status_code == 200
    return str(resp.json()["revision"]["id"])


def _git(args: list[str], cwd: Path) -> None:
    subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)


def test_cutover_archives_legacy_state(tmp_path: Path) -> None:
    legacy_root = tmp_path / ".overdrive"
    legacy_root.mkdir(parents=True)
    (legacy_root / "task_queue.yaml").write_text("tasks: []\n", encoding="utf-8")
    (legacy_root / "run_state.yaml").write_text("{}\n", encoding="utf-8")

    state_root = ensure_state_root(tmp_path)

    assert state_root == tmp_path / ".overdrive"
    assert (state_root / "runtime.db").exists()
    config = Container(tmp_path).config.load()
    assert int(config.get("schema_version") or 0) == 4
    assert str(config.get("storage_backend") or "") == "sqlite"

    archives = sorted(tmp_path.glob(".overdrive_legacy_*"))
    assert len(archives) == 1
    assert (archives[0] / "task_queue.yaml").exists()


def test_cutover_archives_any_non_runtime_state(tmp_path: Path) -> None:
    legacy_root = tmp_path / ".overdrive"
    legacy_root.mkdir(parents=True)
    (legacy_root / "custom_legacy_blob.yaml").write_text("legacy: true\n", encoding="utf-8")

    ensure_state_root(tmp_path)

    archives = sorted(tmp_path.glob(".overdrive_legacy_*"))
    assert len(archives) == 1
    assert (archives[0] / "custom_legacy_blob.yaml").exists()


def test_cutover_forces_schema_version_4(tmp_path: Path) -> None:
    state_root = tmp_path / ".overdrive"
    state_root.mkdir(parents=True)
    (state_root / "config.yaml").write_text("schema_version: 2\n", encoding="utf-8")

    ensure_state_root(tmp_path)

    config = Container(tmp_path).config.load()
    assert int(config.get("schema_version") or 0) == 4
    assert str(config.get("storage_backend") or "") == "sqlite"


def test_yaml_cutover_migrates_all_collections_with_parity(tmp_path: Path) -> None:
    state_root = tmp_path / ".overdrive"
    state_root.mkdir(parents=True)

    task = Task(title="Legacy task", status="queued")
    FileTaskRepository(state_root / "tasks.yaml", state_root / "tasks.lock").upsert(task)

    run = RunRecord(task_id=task.id, status="done", started_at=now_iso(), finished_at=now_iso())
    FileRunRepository(state_root / "runs.yaml", state_root / "runs.lock").upsert(run)

    review = ReviewCycle(task_id=task.id, decision="approved")
    FileReviewRepository(state_root / "review_cycles.yaml", state_root / "review_cycles.lock").append(review)

    agent = AgentRecord(role="general", status="running")
    FileAgentRepository(state_root / "agents.yaml", state_root / "agents.lock").upsert(agent)

    session = TerminalSession(project_id=tmp_path.name, status="running", shell="zsh", cwd=str(tmp_path))
    FileTerminalSessionRepository(
        state_root / "terminal_sessions.yaml",
        state_root / "terminal_sessions.lock",
    ).upsert(session)

    revision = PlanRevision(task_id=task.id, source="human_edit", content="Legacy plan", status="draft")
    FilePlanRevisionRepository(
        state_root / "plan_revisions.yaml",
        state_root / "plan_revisions.lock",
    ).upsert(revision)

    refine_job = PlanRefineJob(task_id=task.id, base_revision_id=revision.id, feedback="Adjust scope")
    FilePlanRefineJobRepository(
        state_root / "plan_refine_jobs.yaml",
        state_root / "plan_refine_jobs.lock",
    ).upsert(refine_job)

    FileConfigRepository(state_root / "config.yaml", state_root / "config.lock").save(
        {"schema_version": 3, "defaults": {"hitl_mode": "autopilot"}}
    )
    event = FileEventRepository(state_root / "events.jsonl", state_root / "events.lock").append(
        channel="tasks",
        event_type="task.created",
        entity_id=task.id,
        payload={"title": task.title},
        project_id=tmp_path.name,
    )

    ensure_state_root(tmp_path)

    assert (state_root / "runtime.db").exists()
    assert not (state_root / "tasks.yaml").exists()
    assert not (state_root / "events.jsonl").exists()

    container = Container(tmp_path)
    assert {item.id for item in container.tasks.list()} == {task.id}
    assert {item.id for item in container.runs.list()} == {run.id}
    assert {item.id for item in container.reviews.list()} == {review.id}
    assert {item.id for item in container.agents.list()} == {agent.id}
    assert {item.id for item in container.terminal_sessions.list()} == {session.id}
    assert {item.id for item in container.plan_revisions.list()} == {revision.id}
    assert {item.id for item in container.plan_refine_jobs.list()} == {refine_job.id}
    assert {evt["id"] for evt in container.events.list_recent(limit=10)} == {event["id"]}

    config = container.config.load()
    assert int(config.get("schema_version") or 0) == 4
    assert str(config.get("storage_backend") or "") == "sqlite"


def test_yaml_cutover_rolls_back_on_migration_failure(tmp_path: Path) -> None:
    state_root = tmp_path / ".overdrive"
    state_root.mkdir(parents=True)

    FileTaskRepository(state_root / "tasks.yaml", state_root / "tasks.lock").upsert(Task(title="Rollback task"))
    FileConfigRepository(state_root / "config.yaml", state_root / "config.lock").save({"schema_version": 3})

    duplicate_event = {
        "id": "evt-duplicate",
        "ts": now_iso(),
        "channel": "tasks",
        "type": "task.created",
        "entity_id": "task-dup",
        "payload": {"x": 1},
        "project_id": tmp_path.name,
    }
    events_path = state_root / "events.jsonl"
    events_path.write_text(
        json.dumps(duplicate_event) + "\n" + json.dumps(duplicate_event) + "\n",
        encoding="utf-8",
    )

    with pytest.raises(RuntimeError, match="Failed to migrate runtime state from YAML to SQLite"):
        ensure_state_root(tmp_path)

    assert (state_root / "tasks.yaml").exists()
    assert (state_root / "events.jsonl").exists()
    assert not (state_root / "runtime.db").exists()


def test_ensure_state_root_fails_closed_on_sqlite_integrity_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    def _raise_integrity(self: SQLiteDB) -> None:
        raise RuntimeError("corrupt")

    monkeypatch.setattr(SQLiteDB, "verify_integrity", _raise_integrity)
    with pytest.raises(RuntimeError, match="Runtime SQLite integrity check failed"):
        ensure_state_root(tmp_path)


def test_task_dependency_guard_blocks_ready_transition(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        blocker = client.post(
            "/api/tasks",
            json={"title": "Blocker", "status": "backlog", "metadata": {"scripted_findings": [[]]}},
        ).json()["task"]
        blocked = client.post("/api/tasks", json={"title": "Blocked", "status": "backlog"}).json()["task"]

        dep_resp = client.post(
            f"/api/tasks/{blocked['id']}/dependencies",
            json={"depends_on": blocker["id"]},
        )
        assert dep_resp.status_code == 200

        # Queueing with unresolved deps is allowed — the scheduler gates execution
        transition = client.post(
            f"/api/tasks/{blocked['id']}/transition",
            json={"status": "queued"},
        )
        assert transition.status_code == 200
        assert transition.json()["task"]["status"] == "queued"

        # But manual run is still blocked while deps are unresolved
        run_resp = client.post(f"/api/tasks/{blocked['id']}/run")
        assert run_resp.status_code == 400
        assert "unresolved blocker" in run_resp.text.lower()

        # Queue and run the blocker so it completes
        client.post(f"/api/tasks/{blocker['id']}/transition", json={"status": "queued"})
        done = client.post(f"/api/tasks/{blocker['id']}/run")
        assert done.status_code == 200
        assert done.json()["task"]["status"] == "done"

        # Now manual run succeeds
        run_ok = client.post(f"/api/tasks/{blocked['id']}/run")
        assert run_ok.status_code == 200
        assert run_ok.json()["task"]["status"] == "done"


def test_execution_leases_use_dedicated_sqlite_table(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Lease table task", "status": "queued"}).json()["task"]
        status = client.get("/api/orchestrator/status")
        assert status.status_code == 200

        key = str(tmp_path.resolve())
        container = app.state.containers[key]
        orchestrator = app.state.orchestrators[key]
        task = container.tasks.get(created["id"])
        assert task is not None

        orchestrator._acquire_execution_lease(task)
        lease_row = container.db.fetch_one(
            "SELECT task_id FROM execution_leases WHERE task_id = ?",
            (task.id,),
        )
        assert lease_row is not None
        assert "execution_lease" not in (task.metadata or {})
        assert orchestrator._execution_lease_active(task) is True

        removed = orchestrator._release_execution_lease(task)
        assert removed is True
        lease_row_after = container.db.fetch_one(
            "SELECT task_id FROM execution_leases WHERE task_id = ?",
            (task.id,),
        )
        assert lease_row_after is None


def test_runtime_state_uses_dedicated_sqlite_table(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        status = client.get("/api/orchestrator/status")
        assert status.status_code == 200

        key = str(tmp_path.resolve())
        container = app.state.containers[key]
        orchestrator = app.state.orchestrators[key]

        orchestrator._last_tick_at = now_iso()
        orchestrator._consecutive_tick_failures = 2
        orchestrator._persist_runtime_state(force=True)

        config = container.config.load()
        assert "orchestrator_state" not in config
        row = container.db.fetch_one(
            "SELECT value FROM orchestrator_state WHERE key = ?",
            ("consecutive_tick_failures",),
        )
        assert row is not None
        assert json.loads(str(row["value"])) == 2


def test_patch_rejects_direct_status_changes(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Patch guarded"}).json()["task"]
        response = client.patch(f"/api/tasks/{task['id']}", json={"status": "done"})
        assert response.status_code == 400
        assert "cannot be changed via PATCH" in response.text


def test_patch_rejects_internal_metadata_keys(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Metadata guarded"}).json()["task"]
        response = client.patch(
            f"/api/tasks/{task['id']}",
            json={"metadata": {"execution_checkpoint": {"gate": "before_implement"}}},
        )
        assert response.status_code == 400
        assert "Only metadata.user.* keys are mutable via PATCH" in str(response.json().get("detail") or "")


def test_patch_allows_user_metadata_namespace(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "User metadata patch"}).json()["task"]
        response = client.patch(
            f"/api/tasks/{task['id']}",
            json={"metadata": {"user": {"owner": "ops"}, "user.note": "do not deploy on friday"}},
        )
        assert response.status_code == 200
        metadata = response.json()["task"].get("metadata") or {}
        assert metadata.get("user", {}).get("owner") == "ops"
        assert metadata.get("user.note") == "do not deploy on friday"


def test_review_actions_require_in_review_state(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Needs status guard"}).json()["task"]
        approve = client.post(f"/api/review/{task['id']}/approve", json={})
        assert approve.status_code == 400
        changes = client.post(f"/api/review/{task['id']}/request-changes", json={"guidance": "x"})
        assert changes.status_code == 400


def test_manual_reconcile_repairs_invalid_queued_gate_state(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        pause = client.post("/api/orchestrator/control", json={"action": "pause"})
        assert pause.status_code == 200
        created = client.post("/api/tasks", json={"title": "Invalid queued gate", "status": "queued"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None
        task.status = "queued"
        task.pending_gate = "before_implement"
        container.tasks.upsert(task)

        resp = client.post("/api/orchestrator/reconcile")
        assert resp.status_code == 200
        body = resp.json()
        summary = body.get("reconcile") or {}
        assert int(summary.get("repairs") or 0) >= 1

        updated = container.tasks.get(created["id"])
        assert updated is not None
        assert updated.status == "blocked"
        assert updated.pending_gate is None


def test_reconcile_does_not_block_pipeline_classify_gate(tmp_path: Path) -> None:
    """pipeline_classify is a valid transient gate on queued tasks — reconciler must not block it."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        pause = client.post("/api/orchestrator/control", json={"action": "pause"})
        assert pause.status_code == 200
        # Create a regular task (not auto) to avoid triggering background classification
        created = client.post("/api/tasks", json={"title": "Test task", "status": "queued"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]

        for gate in ("pipeline_classify", "select_pipeline"):
            task = container.tasks.get(created["id"])
            assert task is not None
            task.status = "queued"
            task.pending_gate = gate
            container.tasks.upsert(task)

            resp = client.post("/api/orchestrator/reconcile")
            assert resp.status_code == 200

            updated = container.tasks.get(created["id"])
            assert updated is not None
            assert updated.status == "queued", f"gate={gate} status={updated.status} error={updated.error}"
            assert updated.pending_gate == gate


def test_classify_pipeline_endpoint_high_confidence(tmp_path: Path) -> None:
    app = create_app(
        project_dir=tmp_path,
        worker_adapter=_ClassifierWorkerAdapter(
            summary='{"pipeline_id":"docs","confidence":"high","reason":"Documentation-only request."}'
        ),
    )
    with TestClient(app) as client:
        resp = client.post(
            "/api/tasks/classify-pipeline",
            json={"title": "Update README", "description": "Refresh setup instructions"},
        )
        assert resp.status_code == 200
        body = resp.json()
        assert body["pipeline_id"] == "docs"
        assert body["task_type"] == "docs"
        assert body["confidence"] == "high"


def test_classify_pipeline_endpoint_invalid_output_downgrades_to_low(tmp_path: Path) -> None:
    app = create_app(
        project_dir=tmp_path,
        worker_adapter=_ClassifierWorkerAdapter(summary="not-json-output"),
    )
    with TestClient(app) as client:
        resp = client.post(
            "/api/tasks/classify-pipeline",
            json={"title": "Something vague", "description": "Need help"},
        )
        assert resp.status_code == 200
        body = resp.json()
        assert body["confidence"] == "low"
        assert body["pipeline_id"] == "feature"


def test_create_task_auto_without_classifier_sets_pending_gate(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        resp = client.post(
            "/api/tasks",
            json={"title": "Auto create", "task_type": "auto"},
        )
        assert resp.status_code == 200
        body = resp.json()["task"]
        assert body["pending_gate"] == "pipeline_classify"


def test_create_task_accepts_auto_with_high_confidence_classification(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        resp = client.post(
            "/api/tasks",
            json={
                "title": "Auto create docs",
                "task_type": "auto",
                "classifier_pipeline_id": "docs",
                "classifier_confidence": "high",
                "classifier_reason": "Docs-only update",
            },
        )
        assert resp.status_code == 200
        body = resp.json()["task"]
        assert body["task_type"] == "docs"
        metadata = body.get("metadata") or {}
        assert metadata["classifier_pipeline_id"] == "docs"
        assert metadata["classifier_confidence"] == "high"
        assert metadata["final_pipeline_id"] == "docs"


def test_create_task_drops_invalid_classifier_metadata_for_manual_task(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        resp = client.post(
            "/api/tasks",
            json={
                "title": "Manual task with bogus classifier metadata",
                "task_type": "feature",
                "classifier_pipeline_id": "not_a_pipeline",
                "classifier_confidence": "high",
                "classifier_reason": "bogus",
                "was_user_override": True,
            },
        )
        assert resp.status_code == 200
        body = resp.json()["task"]
        metadata = body.get("metadata") or {}
        assert "classifier_pipeline_id" not in metadata
        assert "classifier_confidence" not in metadata
        assert "classifier_reason" not in metadata
        assert "was_user_override" not in metadata
        assert metadata["final_pipeline_id"] == "feature"


def test_tasks_board_uses_status_aware_ordering(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        seeded = {
            "backlog_high": client.post("/api/tasks", json={"title": "Backlog high", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
            "backlog_urgent": client.post("/api/tasks", json={"title": "Backlog urgent", "priority": "P0", "status": "backlog"}).json()["task"]["id"],
            "in_progress_old": client.post("/api/tasks", json={"title": "In progress old", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
            "in_progress_new": client.post("/api/tasks", json={"title": "In progress new", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
            "in_review_old": client.post("/api/tasks", json={"title": "In review old", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
            "in_review_new": client.post("/api/tasks", json={"title": "In review new", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
            "blocked_old": client.post("/api/tasks", json={"title": "Blocked old", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
            "blocked_new": client.post("/api/tasks", json={"title": "Blocked new", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
            "done_old": client.post("/api/tasks", json={"title": "Done old", "priority": "P2", "status": "backlog"}).json()["task"]["id"],
            "done_new": client.post("/api/tasks", json={"title": "Done new", "priority": "P3", "status": "backlog"}).json()["task"]["id"],
            "done_p0": client.post("/api/tasks", json={"title": "Done p0", "priority": "P0", "status": "backlog"}).json()["task"]["id"],
            "done_p1": client.post("/api/tasks", json={"title": "Done p1", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
            "cancelled_old": client.post("/api/tasks", json={"title": "Cancelled old", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
            "cancelled_new": client.post("/api/tasks", json={"title": "Cancelled new", "priority": "P1", "status": "backlog"}).json()["task"]["id"],
        }
        container = app.state.containers[str(tmp_path.resolve())]

        for task_id, fields in {
            seeded["backlog_high"]: {
                "status": "backlog",
                "created_at": "2026-01-02T00:00:00+00:00",
                "updated_at": "2026-01-02T00:00:00+00:00",
            },
            seeded["backlog_urgent"]: {
                "status": "backlog",
                "created_at": "2026-01-10T00:00:00+00:00",
                "updated_at": "2026-01-10T00:00:00+00:00",
            },
            seeded["in_progress_old"]: {
                "status": "in_progress",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-05T00:00:00+00:00",
            },
            seeded["in_progress_new"]: {
                "status": "in_progress",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-06T00:00:00+00:00",
            },
            seeded["in_review_old"]: {
                "status": "in_review",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-05T00:00:00+00:00",
            },
            seeded["in_review_new"]: {
                "status": "in_review",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-06T00:00:00+00:00",
            },
            seeded["blocked_old"]: {
                "status": "blocked",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-03T00:00:00+00:00",
            },
            seeded["blocked_new"]: {
                "status": "blocked",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-04T00:00:00+00:00",
            },
            seeded["done_old"]: {
                "status": "done",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-05T00:00:00+00:00",
            },
            seeded["done_new"]: {
                "status": "done",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-07T00:00:00+00:00",
            },
            seeded["done_p0"]: {
                "status": "done",
                "created_at": "2026-01-02T00:00:00+00:00",
                "updated_at": "2026-01-06T00:00:00+00:00",
            },
            seeded["done_p1"]: {
                "status": "done",
                "created_at": "2026-01-03T00:00:00+00:00",
                "updated_at": "2026-01-06T00:00:00+00:00",
            },
            seeded["cancelled_old"]: {
                "status": "cancelled",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-02T00:00:00+00:00",
            },
            seeded["cancelled_new"]: {
                "status": "cancelled",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-03T00:00:00+00:00",
            },
        }.items():
            _patch_task_record(container, task_id, fields)

        board = client.get("/api/tasks/board")
        assert board.status_code == 200
        columns = board.json()["columns"]

        assert [item["id"] for item in columns["backlog"]] == [seeded["backlog_urgent"], seeded["backlog_high"]]
        assert [item["id"] for item in columns["in_progress"]] == [seeded["in_progress_new"], seeded["in_progress_old"]]
        assert [item["id"] for item in columns["in_review"]] == [seeded["in_review_old"], seeded["in_review_new"]]
        assert [item["id"] for item in columns["blocked"]] == [seeded["blocked_new"], seeded["blocked_old"]]
        assert [item["id"] for item in columns["done"]] == [
            seeded["done_new"],
            seeded["done_p0"],
            seeded["done_p1"],
            seeded["done_old"],
        ]
        assert [item["id"] for item in columns["cancelled"]] == [seeded["cancelled_new"], seeded["cancelled_old"]]


def test_tasks_board_sorting_is_deterministic_for_ties_and_missing_timestamps(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        pause = client.post("/api/orchestrator/control", json={"action": "pause"})
        assert pause.status_code == 200
        done_new = client.post("/api/tasks", json={"title": "Done with timestamp", "priority": "P2"}).json()["task"]["id"]
        done_bad_a = client.post("/api/tasks", json={"title": "Done malformed A", "priority": "P2"}).json()["task"]["id"]
        done_bad_b = client.post("/api/tasks", json={"title": "Done malformed B", "priority": "P2"}).json()["task"]["id"]
        backlog_tie_a = client.post("/api/tasks", json={"title": "Backlog tie A", "priority": "P2"}).json()["task"]["id"]
        backlog_tie_b = client.post("/api/tasks", json={"title": "Backlog tie B", "priority": "P2"}).json()["task"]["id"]

        done_tie_ids = sorted([done_bad_a, done_bad_b])
        backlog_tie_ids = sorted([backlog_tie_a, backlog_tie_b])

        container = app.state.containers[str(tmp_path.resolve())]

        for task_id, fields in {
            done_new: {
                "status": "done",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-04T00:00:00+00:00",
            },
            done_bad_a: {
                "status": "done",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "not-a-timestamp",
            },
            done_bad_b: {
                "status": "done",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "not-a-timestamp",
            },
            backlog_tie_a: {
                "status": "backlog",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-01T00:00:00+00:00",
            },
            backlog_tie_b: {
                "status": "backlog",
                "created_at": "2026-01-01T00:00:00+00:00",
                "updated_at": "2026-01-01T00:00:00+00:00",
            },
        }.items():
            _patch_task_record(container, task_id, fields)

        board = client.get("/api/tasks/board")
        assert board.status_code == 200
        columns = board.json()["columns"]

        assert [item["id"] for item in columns["done"]] == [done_new, *done_tie_ids]
        assert [item["id"] for item in columns["backlog"]] == backlog_tie_ids


def test_terminal_session_is_singleton_per_project(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        first = client.post("/api/terminal/session", json={"cols": 100, "rows": 30})
        assert first.status_code == 200
        first_session = first.json()["session"]
        assert first_session["status"] in {"running", "starting"}

        second = client.post("/api/terminal/session", json={"cols": 120, "rows": 40})
        assert second.status_code == 200
        second_session = second.json()["session"]
        assert second_session["id"] == first_session["id"]


def test_terminal_session_io_logs_and_stop(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        start = client.post("/api/terminal/session", json={})
        assert start.status_code == 200
        session = start.json()["session"]
        session_id = session["id"]

        write = client.post(f"/api/terminal/session/{session_id}/input", json={"data": "echo hello\n"})
        assert write.status_code == 200

        output_text = ""
        for _ in range(40):
            logs = client.get(f"/api/terminal/session/{session_id}/logs?offset=0&max_bytes=262144")
            assert logs.status_code == 200
            output_text = str(logs.json().get("output") or "")
            if "hello" in output_text:
                break
            time.sleep(0.05)
        assert "hello" in output_text

        stop = client.post(f"/api/terminal/session/{session_id}/stop", json={"signal": "TERM"})
        assert stop.status_code == 200


def test_project_pin_requires_git_unless_override(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    plain_dir = tmp_path / "plain"
    plain_dir.mkdir()

    with TestClient(app) as client:
        rejected = client.post("/api/projects/pinned", json={"path": str(plain_dir)})
        assert rejected.status_code == 400

        accepted = client.post(
            "/api/projects/pinned",
            json={"path": str(plain_dir), "allow_non_git": True},
        )
        assert accepted.status_code == 200
        listing = client.get("/api/projects/pinned").json()["items"]
        assert len(listing) == 1


def test_pin_project_propagates_worker_default(tmp_path: Path) -> None:
    """Pinning a new project seeds it with the current project's worker preferences."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    new_repo = tmp_path / "new_repo"
    new_repo.mkdir()
    (new_repo / ".git").mkdir()

    with TestClient(app) as client:
        # Set current project's worker config to "claude"
        settings_resp = client.patch(
            "/api/settings",
            json={"workers": {"default": "claude", "default_model": "opus", "providers": {"claude": {"type": "claude"}}}},
        )
        assert settings_resp.status_code == 200

        # Pin the new repo
        pin = client.post("/api/projects/pinned", json={"path": str(new_repo)})
        assert pin.status_code == 200

        # Read the new project's config to verify propagation
        from overdrive.runtime.storage.container import Container
        new_container = Container(new_repo)
        new_cfg = new_container.config.load()
        new_workers = new_cfg.get("workers") or {}
        assert new_workers.get("default") == "claude"
        assert new_workers.get("default_model") == "opus"
        assert "claude" in (new_workers.get("providers") or {})


def test_pin_project_does_not_overwrite_existing_worker_default(tmp_path: Path) -> None:
    """Pinning a project that already has a worker default does not overwrite it."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    new_repo = tmp_path / "existing_repo"
    new_repo.mkdir()
    (new_repo / ".git").mkdir()

    # Pre-configure the new repo with its own worker preference
    from overdrive.runtime.storage.container import Container
    pre_container = Container(new_repo)
    pre_cfg = pre_container.config.load()
    pre_cfg["workers"] = {"default": "ollama"}
    pre_container.config.save(pre_cfg)

    with TestClient(app) as client:
        # Set current project's worker config to "claude"
        client.patch(
            "/api/settings",
            json={"workers": {"default": "claude", "providers": {"claude": {"type": "claude"}}}},
        )

        # Pin the repo that already has its own config
        pin = client.post("/api/projects/pinned", json={"path": str(new_repo)})
        assert pin.status_code == 200

        # Verify existing preference was preserved
        post_container = Container(new_repo)
        post_cfg = post_container.config.load()
        assert post_cfg.get("workers", {}).get("default") == "ollama"


def test_pin_project_no_crash_without_worker_config(tmp_path: Path) -> None:
    """Pinning a project when current project has no worker config does not crash."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    new_repo = tmp_path / "noconfig_repo"
    new_repo.mkdir()
    (new_repo / ".git").mkdir()

    with TestClient(app) as client:
        pin = client.post("/api/projects/pinned", json={"path": str(new_repo)})
        assert pin.status_code == 200


def test_import_preview_commit_creates_dependency_chain(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=_PrdImportWorkerAdapter())
    with TestClient(app) as client:
        preview = client.post(
            "/api/import/prd/preview",
            json={"content": "- First\n- Second\n- Third", "default_priority": "P1"},
        )
        assert preview.status_code == 200
        job_id = preview.json()["job_id"]

        commit = client.post("/api/import/prd/commit", json={"job_id": job_id})
        assert commit.status_code == 200
        created_ids = commit.json()["created_task_ids"]
        assert len(created_ids) == 3

        tasks = {item["id"]: item for item in client.get("/api/tasks").json()["tasks"]}
        assert tasks[created_ids[0]]["blocked_by"] == []
        assert tasks[created_ids[1]]["blocked_by"] == [created_ids[0]]
        assert tasks[created_ids[2]]["blocked_by"] == [created_ids[1]]

        job = client.get(f"/api/import/{job_id}")
        assert job.status_code == 200
        assert job.json()["job"]["created_task_ids"] == created_ids


def test_import_job_persists_when_in_memory_cache_is_empty(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        preview = client.post(
            "/api/import/prd/preview",
            json={"content": "- Persist me"},
        )
        assert preview.status_code == 200
        job_id = preview.json()["job_id"]

        app.state.import_jobs.clear()
        loaded = client.get(f"/api/import/{job_id}")
        assert loaded.status_code == 200
        assert loaded.json()["job"]["id"] == job_id


def test_import_preview_stores_original_and_parsed_prd_artifacts(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        content = "Feature: checkout\n\n- Add API endpoint\n- Add UI flow\n"
        preview = client.post(
            "/api/import/prd/preview",
            json={"content": content, "default_priority": "P1"},
        )
        assert preview.status_code == 200
        payload = preview.json()
        assert payload["preview"]["chunk_count"] >= 1
        assert payload["preview"]["strategy"] in {"heading_section", "paragraph_fallback", "token_window"}

        job_id = payload["job_id"]
        job_resp = client.get(f"/api/import/{job_id}")
        assert job_resp.status_code == 200
        job = job_resp.json()["job"]
        assert job["original_prd"]["content"] == content
        assert job["original_prd"]["checksum_sha256"]
        assert job["parsed_prd"]["chunk_count"] >= 1
        assert len(job["parsed_prd"]["task_candidates"]) >= 1


def test_health_endpoints_available(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        health = client.get("/healthz")
        assert health.status_code == 200
        assert health.json()["status"] == "ok"

        ready = client.get("/readyz")
        assert ready.status_code == 200
        assert ready.json()["status"] == "ready"


def test_agent_remove_supports_delete_and_post(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        first = client.post("/api/agents/spawn", json={"role": "general", "capacity": 1})
        assert first.status_code == 200
        first_id = first.json()["agent"]["id"]

        delete_resp = client.delete(f"/api/agents/{first_id}")
        assert delete_resp.status_code == 200
        assert delete_resp.json()["removed"] is True

        second = client.post("/api/agents/spawn", json={"role": "general", "capacity": 1})
        assert second.status_code == 200
        second_id = second.json()["agent"]["id"]

        post_resp = client.post(f"/api/agents/{second_id}/remove")
        assert post_resp.status_code == 200
        assert post_resp.json()["removed"] is True

        missing = client.delete("/api/agents/does-not-exist")
        assert missing.status_code == 404


def test_workers_health_and_routing_endpoints(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        health = client.get("/api/workers/health")
        assert health.status_code == 200
        providers = health.json()["providers"]
        names = {item["name"] for item in providers}
        assert "codex" in names
        assert "claude" in names
        assert "ollama" in names

        routing = client.get("/api/workers/routing")
        assert routing.status_code == 200
        payload = routing.json()
        assert payload["default"] == "codex"
        assert any(item["step"] == "implement" for item in payload["rows"])


def test_legacy_compat_endpoints_available(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Compat seed"}).json()["task"]

        metrics = client.get("/api/metrics")
        assert metrics.status_code == 200
        assert "tasks_completed" in metrics.json()

        phases = client.get("/api/phases")
        assert phases.status_code == 200
        assert isinstance(phases.json(), list)

        agent_types = client.get("/api/agents/types")
        assert agent_types.status_code == 200
        assert len(agent_types.json()["types"]) > 0

        modes = client.get("/api/collaboration/modes")
        assert modes.status_code == 200
        assert len(modes.json()["modes"]) > 0

        add_feedback = client.post(
            "/api/collaboration/feedback",
            json={"task_id": created["id"], "summary": "Need stricter checks"},
        )
        assert add_feedback.status_code == 200
        feedback_id = add_feedback.json()["feedback"]["id"]

        add_comment = client.post(
            "/api/collaboration/comments",
            json={"task_id": created["id"], "file_path": "main.py", "line_number": 1, "body": "Looks good"},
        )
        assert add_comment.status_code == 200
        comment_id = add_comment.json()["comment"]["id"]

        feedback_list = client.get(f"/api/collaboration/feedback/{created['id']}")
        assert feedback_list.status_code == 200
        assert any(item["id"] == feedback_id for item in feedback_list.json()["feedback"])

        comment_list = client.get(f"/api/collaboration/comments/{created['id']}")
        assert comment_list.status_code == 200
        assert any(item["id"] == comment_id for item in comment_list.json()["comments"])

        dismissed = client.post(f"/api/collaboration/feedback/{feedback_id}/dismiss")
        assert dismissed.status_code == 200
        assert dismissed.json()["feedback"]["status"] == "addressed"

        resolved = client.post(f"/api/collaboration/comments/{comment_id}/resolve")
        assert resolved.status_code == 200
        assert resolved.json()["comment"]["resolved"] is True

        timeline = client.get(f"/api/collaboration/timeline/{created['id']}")
        assert timeline.status_code == 200
        assert len(timeline.json()["events"]) >= 1
        types = {event["type"] for event in timeline.json()["events"]}
        assert "feedback" in types
        assert "comment" in types


def test_settings_endpoint_round_trip(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        baseline = client.get("/api/settings")
        assert baseline.status_code == 200
        assert baseline.json()["orchestrator"]["concurrency"] == 2
        assert baseline.json()["orchestrator"]["auto_deps"] is True
        assert baseline.json()["orchestrator"]["max_review_attempts"] == 10
        assert baseline.json()["orchestrator"]["max_merge_conflict_attempts"] == 3
        assert baseline.json()["orchestrator"]["step_timeout_seconds"] == 0
        assert baseline.json()["orchestrator"]["gate_reminder_minutes"] == 30
        assert baseline.json()["orchestrator"]["gate_stale_minutes"] == 0
        assert baseline.json()["orchestrator"]["gate_max_wait_minutes"] == 0
        assert baseline.json()["orchestrator"]["gate_timeout_action"] == "none"
        assert baseline.json()["agent_routing"]["default_role"] == "general"
        assert baseline.json()["defaults"]["quality_gate"]["high"] == 0
        assert baseline.json()["defaults"]["task_generation"] == {
            "child_status": "backlog",
            "child_hitl_mode": "inherit_parent",
            "infer_deps": True,
        }
        assert baseline.json()["workers"]["default"] == "codex"
        assert baseline.json()["workers"]["heartbeat_seconds"] == 60
        assert baseline.json()["workers"]["heartbeat_grace_seconds"] == 240
        assert baseline.json()["workers"]["providers"]["codex"]["type"] == "codex"
        assert baseline.json()["workers"]["environment"] == {
            "auto_prepare": True,
            "max_auto_retries": 3,
            "capability_fallback": True,
            "required_capabilities_by_step": {},
        }

        updated = client.patch(
            "/api/settings",
            json={
                "orchestrator": {
                    "concurrency": 5,
                    "auto_deps": False,
                    "max_review_attempts": 4,
                    "max_merge_conflict_attempts": 6,
                    "step_timeout_seconds": 900,
                    "gate_reminder_minutes": 15,
                    "gate_stale_minutes": 120,
                    "gate_max_wait_minutes": 240,
                    "gate_timeout_action": "block",
                },
                "agent_routing": {
                    "default_role": "reviewer",
                    "task_type_roles": {"bug": "debugger"},
                    "role_provider_overrides": {"reviewer": "openai"},
                },
                "defaults": {
                    "quality_gate": {"critical": 1, "high": 2, "medium": 3, "low": 4},
                    "task_generation": {
                        "child_status": "queued",
                        "child_hitl_mode": "review_only",
                        "infer_deps": False,
                    },
                },
                "workers": {
                    "default": "ollama-dev",
                    "default_model": "gpt-5-codex",
                    "heartbeat_seconds": 90,
                    "heartbeat_grace_seconds": 360,
                    "routing": {"plan": "codex", "implement": "ollama-dev"},
                    "environment": {
                        "auto_prepare": True,
                        "max_auto_retries": 5,
                        "capability_fallback": True,
                        "required_capabilities_by_step": {
                            "verify": ["docker", "network"],
                        },
                    },
                    "providers": {
                        "codex": {
                            "type": "codex",
                            "command": "codex exec",
                            "model": "gpt-5-codex",
                            "reasoning_effort": "high",
                            "execution_mode": "host_access",
                            "capabilities": ["docker", "network"],
                        },
                        "ollama-dev": {
                            "type": "ollama",
                            "endpoint": "http://localhost:11434",
                            "model": "llama3.1:8b",
                            "temperature": 0.2,
                            "num_ctx": 8192,
                        },
                        "claude": {
                            "type": "claude",
                            "command": "claude -p",
                            "model": "sonnet",
                            "reasoning_effort": "medium",
                            "execution_mode": "sandboxed",
                        },
                    },
                },
            },
        )
        assert updated.status_code == 200
        body = updated.json()
        assert body["orchestrator"]["concurrency"] == 5
        assert body["orchestrator"]["auto_deps"] is False
        assert body["orchestrator"]["max_review_attempts"] == 4
        assert body["orchestrator"]["max_merge_conflict_attempts"] == 6
        assert body["orchestrator"]["step_timeout_seconds"] == 900
        assert body["orchestrator"]["gate_reminder_minutes"] == 15
        assert body["orchestrator"]["gate_stale_minutes"] == 120
        assert body["orchestrator"]["gate_max_wait_minutes"] == 240
        assert body["orchestrator"]["gate_timeout_action"] == "block"
        assert body["agent_routing"]["default_role"] == "reviewer"
        assert body["agent_routing"]["task_type_roles"]["bug"] == "debugger"
        assert body["agent_routing"]["role_provider_overrides"]["reviewer"] == "openai"
        assert body["defaults"]["quality_gate"]["critical"] == 1
        assert body["defaults"]["quality_gate"]["high"] == 2
        assert body["defaults"]["quality_gate"]["medium"] == 3
        assert body["defaults"]["quality_gate"]["low"] == 4
        assert body["defaults"]["task_generation"] == {
            "child_status": "queued",
            "child_hitl_mode": "review_only",
            "infer_deps": False,
        }
        assert body["workers"]["default"] == "ollama-dev"
        assert body["workers"]["default_model"] == "gpt-5-codex"
        assert body["workers"]["heartbeat_seconds"] == 90
        assert body["workers"]["heartbeat_grace_seconds"] == 360
        assert body["workers"]["routing"]["plan"] == "codex"
        assert body["workers"]["routing"]["implement"] == "ollama-dev"
        assert body["workers"]["environment"] == {
            "auto_prepare": True,
            "max_auto_retries": 5,
            "capability_fallback": True,
            "required_capabilities_by_step": {"verify": ["docker", "network"]},
        }
        assert body["workers"]["providers"]["codex"]["type"] == "codex"
        assert body["workers"]["providers"]["codex"]["model"] == "gpt-5-codex"
        assert body["workers"]["providers"]["codex"]["reasoning_effort"] == "high"
        assert body["workers"]["providers"]["codex"]["execution_mode"] == "host_access"
        assert body["workers"]["providers"]["codex"]["capabilities"] == ["docker", "network"]
        assert body["workers"]["providers"]["ollama-dev"]["type"] == "ollama"
        assert body["workers"]["providers"]["ollama-dev"]["model"] == "llama3.1:8b"
        assert body["workers"]["providers"]["claude"]["type"] == "claude"
        assert body["workers"]["providers"]["claude"]["command"] == "claude -p"
        assert body["workers"]["providers"]["claude"]["model"] == "sonnet"
        assert body["workers"]["providers"]["claude"]["reasoning_effort"] == "medium"
        assert body["workers"]["providers"]["claude"]["execution_mode"] == "sandboxed"

        reloaded = client.get("/api/settings")
        assert reloaded.status_code == 200
        reloaded_body = reloaded.json()
        # Exclude transient display-only keys that only appear in GET
        for k in ("auto_detected_defaults", "detected_python_venv", "resolved_env_vars"):
            reloaded_body.pop(k, None)
            body.pop(k, None)
        assert reloaded_body == body


def test_settings_patch_preserves_unspecified_orchestrator_fields(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        seeded = client.patch(
            "/api/settings",
            json={
                "orchestrator": {
                    "concurrency": 3,
                    "auto_deps": True,
                    "max_review_attempts": 10,
                    "max_merge_conflict_attempts": 7,
                    "step_timeout_seconds": 600,
                    "gate_reminder_minutes": 5,
                    "gate_stale_minutes": 7,
                    "gate_max_wait_minutes": 9,
                    "gate_timeout_action": "block",
                }
            },
        )
        assert seeded.status_code == 200

        partial = client.patch(
            "/api/settings",
            json={"orchestrator": {"concurrency": 4}},
        )
        assert partial.status_code == 200
        orch = partial.json()["orchestrator"]
        assert orch["concurrency"] == 4
        assert orch["max_merge_conflict_attempts"] == 7
        assert orch["gate_reminder_minutes"] == 5
        assert orch["gate_stale_minutes"] == 7
        assert orch["gate_max_wait_minutes"] == 9
        assert orch["gate_timeout_action"] == "block"


def test_settings_patch_rejects_invalid_merge_conflict_attempts(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        too_low = client.patch(
            "/api/settings",
            json={"orchestrator": {"max_merge_conflict_attempts": 0}},
        )
        assert too_low.status_code == 422

        too_high = client.patch(
            "/api/settings",
            json={"orchestrator": {"max_merge_conflict_attempts": 11}},
        )
        assert too_high.status_code == 422

        non_int = client.patch(
            "/api/settings",
            json={"orchestrator": {"max_merge_conflict_attempts": "abc"}},
        )
        assert non_int.status_code == 422


def test_create_task_worker_model_round_trip(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Model override", "worker_model": "gpt-5-codex"})
        assert created.status_code == 200
        task = created.json()["task"]
        assert task["worker_model"] == "gpt-5-codex"

        loaded = client.get(f"/api/tasks/{task['id']}")
        assert loaded.status_code == 200
        assert loaded.json()["task"]["worker_model"] == "gpt-5-codex"


def test_task_step_timeout_round_trip(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Timeout override", "step_timeout_seconds": 777})
        assert created.status_code == 200
        task = created.json()["task"]
        assert task["step_timeout_seconds"] == 777
        assert task["metadata"]["step_timeouts"]["implement"] == 777

        updated = client.patch(
            f"/api/tasks/{task['id']}",
            json={"step_timeout_seconds": 333},
        )
        assert updated.status_code == 200
        assert updated.json()["task"]["step_timeout_seconds"] == 333
        assert updated.json()["task"]["metadata"]["step_timeouts"]["implement"] == 333

        cleared = client.patch(
            f"/api/tasks/{task['id']}",
            json={"step_timeout_seconds": None},
        )
        assert cleared.status_code == 200
        assert cleared.json()["task"]["step_timeout_seconds"] is None
        assert "step_timeouts" not in (cleared.json()["task"]["metadata"] or {})


def test_create_task_strips_internal_context_metadata_keys(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Sanitize metadata",
                "metadata": {
                    "worktree_dir": "/tmp/bad",
                    "task_context": {"expected_on_retry": True},
                    "preserved_branch": "task-bad",
                    "user.note": "keep",
                },
            },
        )
        assert created.status_code == 200
        task = created.json()["task"]
        metadata = task["metadata"] or {}
        assert "worktree_dir" not in metadata
        assert "task_context" not in metadata
        assert "preserved_branch" not in metadata
        assert metadata.get("user.note") == "keep"


def test_task_payload_includes_gate_context(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Gate context task", "status": "backlog"}).json()["task"]
        task = container.tasks.get(created["id"])
        assert task is not None
        task.status = "in_progress"
        task.current_step = "plan"
        task.pending_gate = "before_implement"
        container.tasks.upsert(task)

        loaded = client.get(f"/api/tasks/{task.id}")
        assert loaded.status_code == 200
        gate_context = loaded.json()["task"]["gate_context"]
        assert gate_context["is_waiting"] is True
        assert gate_context["gate"] == "before_implement"
        assert gate_context["display"] == "Plan ready"
        assert gate_context["step"] == "plan"
        assert gate_context["status_kind"] == "approval_wait"


def test_get_task_includes_timing_summary_for_completed_runs(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Timing complete runs", "status": "backlog"}).json()["task"]
        task = container.tasks.get(created["id"])
        assert task is not None

        run_one = RunRecord(
            task_id=task.id,
            status="done",
            started_at="2026-02-21T10:00:00Z",
            finished_at="2026-02-21T10:00:30Z",
        )
        run_two = RunRecord(
            task_id=task.id,
            status="done",
            started_at="2026-02-21T10:02:00Z",
            finished_at="2026-02-21T10:03:00Z",
        )
        container.runs.upsert(run_one)
        container.runs.upsert(run_two)
        task.run_ids = [run_one.id, run_two.id]
        container.tasks.upsert(task)

        loaded = client.get(f"/api/tasks/{task.id}")
        assert loaded.status_code == 200
        timing = loaded.json()["task"]["timing_summary"]
        assert timing["is_running"] is False
        assert timing["active_run_started_at"] is None
        assert timing["total_completed_seconds"] == 90.0
        assert timing["first_started_at"] == "2026-02-21T10:00:00+00:00"
        assert timing["last_finished_at"] == "2026-02-21T10:03:00+00:00"


def test_get_task_timing_summary_keeps_completed_total_while_running(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Timing running run", "status": "backlog"}).json()["task"]
        task = container.tasks.get(created["id"])
        assert task is not None

        completed = RunRecord(
            task_id=task.id,
            status="done",
            started_at="2026-02-21T11:00:00Z",
            finished_at="2026-02-21T11:01:00Z",
        )
        active = RunRecord(
            task_id=task.id,
            status="in_progress",
            started_at="2026-02-21T11:05:00Z",
            finished_at=None,
        )
        container.runs.upsert(completed)
        container.runs.upsert(active)
        task.run_ids = [completed.id, active.id]
        container.tasks.upsert(task)

        loaded = client.get(f"/api/tasks/{task.id}")
        assert loaded.status_code == 200
        timing = loaded.json()["task"]["timing_summary"]
        assert timing["is_running"] is True
        assert timing["active_run_started_at"] == "2026-02-21T11:05:00+00:00"
        assert timing["total_completed_seconds"] == 60.0
        assert timing["first_started_at"] == "2026-02-21T11:00:00+00:00"
        assert timing["last_finished_at"] == "2026-02-21T11:01:00+00:00"


def test_get_task_timing_summary_ignores_malformed_timestamps(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Timing malformed", "status": "backlog"}).json()["task"]
        task = container.tasks.get(created["id"])
        assert task is not None

        bad_completed = RunRecord(
            task_id=task.id,
            status="done",
            started_at="not-a-timestamp",
            finished_at="2026-02-21T12:01:00Z",
        )
        bad_running = RunRecord(
            task_id=task.id,
            status="in_progress",
            started_at="bad-start",
            finished_at=None,
        )
        container.runs.upsert(bad_completed)
        container.runs.upsert(bad_running)
        task.run_ids = [bad_completed.id, bad_running.id]
        container.tasks.upsert(task)

        loaded = client.get(f"/api/tasks/{task.id}")
        assert loaded.status_code == 200
        timing = loaded.json()["task"]["timing_summary"]
        assert timing["is_running"] is False
        assert timing["active_run_started_at"] is None
        assert timing["total_completed_seconds"] == 0.0
        assert timing["first_started_at"] is None
        assert timing["last_finished_at"] == "2026-02-21T12:01:00+00:00"


def test_get_task_timing_summary_handles_mixed_timezone_formats(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={"title": "Timing mixed timezone formats", "status": "backlog"},
        ).json()["task"]
        task = container.tasks.get(created["id"])
        assert task is not None

        aware_run = RunRecord(
            task_id=task.id,
            status="done",
            started_at="2026-02-21T10:00:00Z",
            finished_at="2026-02-21T10:00:30+00:00",
        )
        naive_run = RunRecord(
            task_id=task.id,
            status="done",
            started_at="2026-02-21T10:01:00",
            finished_at="2026-02-21T10:02:00",
        )
        active_naive = RunRecord(
            task_id=task.id,
            status="in_progress",
            started_at="2026-02-21T10:03:00",
            finished_at=None,
        )
        container.runs.upsert(aware_run)
        container.runs.upsert(naive_run)
        container.runs.upsert(active_naive)
        task.run_ids = [aware_run.id, naive_run.id, active_naive.id]
        container.tasks.upsert(task)

        loaded = client.get(f"/api/tasks/{task.id}")
        assert loaded.status_code == 200
        timing = loaded.json()["task"]["timing_summary"]
        assert timing["is_running"] is True
        assert timing["active_run_started_at"] == "2026-02-21T10:03:00+00:00"
        assert timing["total_completed_seconds"] == 90.0
        assert timing["first_started_at"] == "2026-02-21T10:00:00+00:00"
        assert timing["last_finished_at"] == "2026-02-21T10:02:00+00:00"


def test_task_logs_step_query_does_not_fallback_to_other_step_logs(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={"title": "Step-scoped logs", "status": "backlog"},
        ).json()["task"]

        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None

        logs_dir = container.state_root / "logs"
        logs_dir.mkdir(parents=True, exist_ok=True)
        plan_stdout = logs_dir / "plan.stdout.log"
        plan_stdout.write_text("plan output\n", encoding="utf-8")

        run = RunRecord(
            task_id=task.id,
            status="in_progress",
            started_at=now_iso(),
            steps=[
                {
                    "step": "plan",
                    "status": "ok",
                    "stdout_path": str(plan_stdout),
                    "started_at": now_iso(),
                    "ts": now_iso(),
                }
            ],
        )
        container.runs.upsert(run)
        task.run_ids = [run.id]
        task.status = "in_progress"
        task.current_step = "implement"
        task.pending_gate = "before_implement"
        task.metadata["last_logs"] = {"step": "plan", "stdout_path": str(plan_stdout)}
        container.tasks.upsert(task)

        scoped = client.get(f"/api/tasks/{task.id}/logs?step=implement")
        assert scoped.status_code == 200
        body = scoped.json()
        assert body["mode"] == "none"
        assert body["step"] == "implement"
        assert body["stdout"] == ""
        assert body["available_steps"] == ["plan"]


def test_task_logs_step_query_reads_active_logs_for_requested_step(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Active step logs"}).json()["task"]

        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None

        logs_dir = container.state_root / "logs"
        logs_dir.mkdir(parents=True, exist_ok=True)
        implement_stdout = logs_dir / "implement.stdout.log"
        implement_stdout.write_text("implement output\n", encoding="utf-8")

        task.status = "in_progress"
        task.current_step = "implement"
        task.metadata["active_logs"] = {"step": "implement", "stdout_path": str(implement_stdout)}
        container.tasks.upsert(task)

        scoped = client.get(f"/api/tasks/{task.id}/logs?step=implement")
        assert scoped.status_code == 200
        body = scoped.json()
        assert body["mode"] == "active"
        assert body["step"] == "implement"
        assert body["stdout"] == "implement output\n"
        assert body["available_steps"] == ["implement"]


def test_task_logs_include_steps_from_all_runs_and_support_run_id_selection(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Log continuity"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None

        logs_dir = container.state_root / "logs"
        logs_dir.mkdir(parents=True, exist_ok=True)
        verify_old = logs_dir / "verify.old.stdout.log"
        verify_old.write_text("verify old\n", encoding="utf-8")
        verify_new = logs_dir / "verify.new.stdout.log"
        verify_new.write_text("verify new\n", encoding="utf-8")
        plan_old = logs_dir / "plan.old.stdout.log"
        plan_old.write_text("plan old\n", encoding="utf-8")

        run_old = RunRecord(
            task_id=task.id,
            status="done",
            started_at=now_iso(),
            finished_at=now_iso(),
            steps=[
                {"step": "plan", "status": "ok", "stdout_path": str(plan_old), "started_at": now_iso(), "ts": now_iso()},
                {"step": "verify", "status": "ok", "stdout_path": str(verify_old), "started_at": now_iso(), "ts": now_iso()},
            ],
        )
        run_new = RunRecord(
            task_id=task.id,
            status="done",
            started_at=now_iso(),
            finished_at=now_iso(),
            steps=[
                {"step": "verify", "status": "ok", "stdout_path": str(verify_new), "started_at": now_iso(), "ts": now_iso()},
            ],
        )
        container.runs.upsert(run_old)
        container.runs.upsert(run_new)
        task.run_ids = [run_old.id, run_new.id]
        task.status = "in_progress"
        task.current_step = "verify"
        task.metadata["active_logs"] = {"step": "verify", "stdout_path": str(verify_new), "run_id": run_new.id}
        container.tasks.upsert(task)

        merged = client.get(f"/api/tasks/{task.id}/logs")
        assert merged.status_code == 200
        merged_body = merged.json()
        assert "verify" in merged_body["available_steps"]
        assert "plan" in merged_body["available_steps"]
        assert merged_body["step_execution_counts"]["verify"] == 2
        assert merged_body["step_latest_run"]["verify"] == run_new.id
        assert any(item["step"] == "verify" and item["run_id"] == run_old.id for item in merged_body["step_history"])
        assert any(item["step"] == "verify" and item["run_id"] == run_new.id for item in merged_body["step_history"])
        verify_attempts = {
            item["run_id"]: int(item["attempt"])
            for item in merged_body["step_history"]
            if item["step"] == "verify"
        }
        assert verify_attempts.get(run_old.id) == 1
        assert verify_attempts.get(run_new.id) == 2

        scoped_old = client.get(f"/api/tasks/{task.id}/logs?step=verify&run_id={run_old.id}")
        assert scoped_old.status_code == 200
        old_body = scoped_old.json()
        assert old_body["mode"] == "history"
        assert old_body["run_id"] == run_old.id
        assert old_body["stdout"] == "verify old\n"

        scoped_new = client.get(f"/api/tasks/{task.id}/logs?step=verify&run_id={run_new.id}")
        assert scoped_new.status_code == 200
        new_body = scoped_new.json()
        assert new_body["mode"] == "history"
        assert new_body["run_id"] == run_new.id
        assert new_body["stdout"] == "verify new\n"

        strict_miss = client.get(f"/api/tasks/{task.id}/logs?step=verify&run_id=run_missing")
        assert strict_miss.status_code == 200
        strict_miss_body = strict_miss.json()
        assert strict_miss_body["mode"] == "none"
        assert strict_miss_body["run_id"] is None
        assert strict_miss_body["stdout"] == ""


def test_task_logs_latest_selection_uses_run_timestamps_not_run_ids_order(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Log ordering resilience"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None

        logs_dir = container.state_root / "logs"
        logs_dir.mkdir(parents=True, exist_ok=True)
        verify_old = logs_dir / "verify.ts.old.stdout.log"
        verify_old.write_text("verify ts old\n", encoding="utf-8")
        verify_new = logs_dir / "verify.ts.new.stdout.log"
        verify_new.write_text("verify ts new\n", encoding="utf-8")

        run_old = RunRecord(
            task_id=task.id,
            status="done",
            started_at="2026-03-01T14:44:01Z",
            finished_at="2026-03-01T14:44:31Z",
            steps=[
                {
                    "step": "verify",
                    "status": "ok",
                    "stdout_path": str(verify_old),
                    "started_at": "2026-03-01T14:44:01Z",
                    "ts": "2026-03-01T14:44:31Z",
                },
            ],
        )
        run_new = RunRecord(
            task_id=task.id,
            status="done",
            started_at="2026-03-01T14:48:03Z",
            finished_at="2026-03-01T14:50:33Z",
            steps=[
                {
                    "step": "verify",
                    "status": "ok",
                    "stdout_path": str(verify_new),
                    "started_at": "2026-03-01T14:48:03Z",
                    "ts": "2026-03-01T14:50:33Z",
                },
            ],
        )
        container.runs.upsert(run_old)
        container.runs.upsert(run_new)

        # Intentionally reverse run_ids to emulate legacy/inconsistent ordering.
        task.run_ids = [run_new.id, run_old.id]
        task.status = "in_progress"
        task.current_step = "verify"
        container.tasks.upsert(task)

        merged = client.get(f"/api/tasks/{task.id}/logs?step=verify")
        assert merged.status_code == 200
        body = merged.json()
        assert body["run_id"] == run_new.id
        assert body["step_latest_run"]["verify"] == run_new.id
        verify_attempts = {
            item["run_id"]: int(item["attempt"])
            for item in body["step_history"]
            if item["step"] == "verify"
        }
        assert verify_attempts.get(run_old.id) == 1
        assert verify_attempts.get(run_new.id) == 2


def test_task_logs_default_selection_prefers_latest_history_over_stale_last_logs(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Default logs selection"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None

        logs_dir = container.state_root / "logs"
        logs_dir.mkdir(parents=True, exist_ok=True)
        verify_old = logs_dir / "verify.default.old.stdout.log"
        verify_old.write_text("verify default old\n", encoding="utf-8")
        verify_new = logs_dir / "verify.default.new.stdout.log"
        verify_new.write_text("verify default new\n", encoding="utf-8")

        run_old = RunRecord(
            task_id=task.id,
            status="done",
            started_at="2026-03-01T14:44:01Z",
            finished_at="2026-03-01T14:44:31Z",
            steps=[
                {
                    "step": "verify",
                    "status": "ok",
                    "stdout_path": str(verify_old),
                    "started_at": "2026-03-01T14:44:01Z",
                    "ts": "2026-03-01T14:44:31Z",
                },
            ],
        )
        run_new = RunRecord(
            task_id=task.id,
            status="done",
            started_at="2026-03-01T14:48:03Z",
            finished_at="2026-03-01T14:50:33Z",
            steps=[
                {
                    "step": "verify",
                    "status": "ok",
                    "stdout_path": str(verify_new),
                    "started_at": "2026-03-01T14:48:03Z",
                    "ts": "2026-03-01T14:50:33Z",
                },
            ],
        )
        container.runs.upsert(run_old)
        container.runs.upsert(run_new)

        task.run_ids = [run_old.id, run_new.id]
        task.status = "done"
        task.current_step = "verify"
        task.metadata = dict(task.metadata or {})
        task.metadata["last_logs"] = {"step": "verify", "stdout_path": str(verify_old), "run_id": run_old.id}
        container.tasks.upsert(task)

        merged = client.get(f"/api/tasks/{task.id}/logs")
        assert merged.status_code == 200
        body = merged.json()
        assert body["mode"] == "history"
        assert body["run_id"] == run_new.id
        assert body["stdout"] == "verify default new\n"


def test_cancel_task_clears_pending_gate_and_checkpoint(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Cancel clears gate"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None
        task.status = "in_progress"
        task.pending_gate = "before_implement"
        task.current_agent_id = "agent-1"
        task.metadata = dict(task.metadata or {})
        task.metadata["execution_checkpoint"] = {"gate": "before_implement", "resume_step": "implement"}
        container.tasks.upsert(task)

        resp = client.post(f"/api/tasks/{task.id}/cancel")
        assert resp.status_code == 200
        body = resp.json()["task"]
        assert body["status"] == "cancelled"
        assert body["pending_gate"] is None

        updated = container.tasks.get(task.id)
        assert updated is not None
        assert updated.pending_gate is None
        assert updated.current_agent_id is None
        assert "execution_checkpoint" not in (updated.metadata or {})


def test_cancel_task_immediately_cleans_retained_context_and_branch(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "init",
        ],
        tmp_path,
    )

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Cancel cleanup"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None

        retained_branch = f"task-{task.id}"
        retained_worktree = container.state_root / "worktrees" / task.id
        subprocess.run(
            ["git", "worktree", "add", str(retained_worktree), "-b", retained_branch],
            cwd=tmp_path,
            check=True,
            capture_output=True,
            text=True,
        )
        task.status = "blocked"
        task.metadata = {
            "worktree_dir": str(retained_worktree),
            "task_context": {
                "context_id": "ctx-cancel-cleanup",
                "worktree_dir": str(retained_worktree),
                "task_branch": retained_branch,
                "retained": True,
                "expected_on_retry": True,
            },
        }
        container.tasks.upsert(task)

        resp = client.post(f"/api/tasks/{task.id}/cancel")
        assert resp.status_code == 200
        payload = resp.json()["task"]
        assert payload["status"] == "cancelled"

        updated = container.tasks.get(task.id)
        assert updated is not None
        assert not retained_worktree.exists()
        branch_listing = subprocess.run(
            ["git", "branch", "--list", retained_branch],
            cwd=tmp_path,
            capture_output=True,
            text=True,
            check=True,
        ).stdout.strip()
        assert branch_listing == ""
        metadata = updated.metadata if isinstance(updated.metadata, dict) else {}
        assert "worktree_dir" not in metadata
        assert "task_context" not in metadata
        assert "cancel_cleanup_pending" not in metadata


def test_transition_to_cancelled_uses_same_cleanup_path(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "init",
        ],
        tmp_path,
    )

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Transition cancel cleanup"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None

        retained_branch = f"task-{task.id}"
        retained_worktree = container.state_root / "worktrees" / task.id
        subprocess.run(
            ["git", "worktree", "add", str(retained_worktree), "-b", retained_branch],
            cwd=tmp_path,
            check=True,
            capture_output=True,
            text=True,
        )
        task.status = "blocked"
        task.metadata = {
            "worktree_dir": str(retained_worktree),
            "task_context": {
                "context_id": "ctx-transition-cancel",
                "worktree_dir": str(retained_worktree),
                "task_branch": retained_branch,
                "retained": True,
                "expected_on_retry": True,
            },
        }
        container.tasks.upsert(task)

        resp = client.post(f"/api/tasks/{task.id}/transition", json={"status": "cancelled"})
        assert resp.status_code == 200
        payload = resp.json()["task"]
        assert payload["status"] == "cancelled"

        updated = container.tasks.get(task.id)
        assert updated is not None
        assert not retained_worktree.exists()
        branch_listing = subprocess.run(
            ["git", "branch", "--list", retained_branch],
            cwd=tmp_path,
            capture_output=True,
            text=True,
            check=True,
        ).stdout.strip()
        assert branch_listing == ""
        metadata = updated.metadata if isinstance(updated.metadata, dict) else {}
        assert "worktree_dir" not in metadata
        assert "task_context" not in metadata
        assert "cancel_cleanup_pending" not in metadata


def test_get_task_timing_summary_prefers_earliest_active_run(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={"title": "Timing active run selection", "status": "backlog"},
        ).json()["task"]
        task = container.tasks.get(created["id"])
        assert task is not None

        early_active = RunRecord(
            task_id=task.id,
            status="in_progress",
            started_at="2026-02-21T10:03:00Z",
            finished_at=None,
        )
        late_active = RunRecord(
            task_id=task.id,
            status="in_progress",
            started_at="2026-02-24T06:00:40.282283Z",
            finished_at=None,
        )
        container.runs.upsert(early_active)
        container.runs.upsert(late_active)
        task.run_ids = [late_active.id, early_active.id]
        container.tasks.upsert(task)

        loaded = client.get(f"/api/tasks/{task.id}")
        assert loaded.status_code == 200
        timing = loaded.json()["task"]["timing_summary"]
        assert timing["is_running"] is True
        assert timing["active_run_started_at"] == "2026-02-21T10:03:00+00:00"
        assert timing["total_completed_seconds"] == 0.0
        assert timing["first_started_at"] == "2026-02-21T10:03:00+00:00"
        assert timing["last_finished_at"] is None


def test_project_commands_settings_round_trip(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        # Baseline: empty commands
        baseline = client.get("/api/settings")
        assert baseline.status_code == 200
        assert baseline.json()["project"]["commands"] == {}

        # Set python commands
        resp = client.patch(
            "/api/settings",
            json={"project": {"commands": {"python": {"test": "pytest -n auto", "lint": "ruff check ."}}}},
        )
        assert resp.status_code == 200
        cmds = resp.json()["project"]["commands"]
        assert cmds["python"]["test"] == "pytest -n auto"
        assert cmds["python"]["lint"] == "ruff check ."

        # Reload and verify persistence
        reloaded = client.get("/api/settings")
        assert reloaded.json()["project"]["commands"] == cmds

        # Merge: add typecheck, leave test and lint untouched
        resp2 = client.patch(
            "/api/settings",
            json={"project": {"commands": {"python": {"typecheck": "mypy ."}}}},
        )
        assert resp2.status_code == 200
        cmds2 = resp2.json()["project"]["commands"]["python"]
        assert cmds2["test"] == "pytest -n auto"
        assert cmds2["lint"] == "ruff check ."
        assert cmds2["typecheck"] == "mypy ."

        # Remove a field by setting to empty string
        resp3 = client.patch(
            "/api/settings",
            json={"project": {"commands": {"python": {"lint": ""}}}},
        )
        assert resp3.status_code == 200
        cmds3 = resp3.json()["project"]["commands"]["python"]
        assert "lint" not in cmds3
        assert cmds3["test"] == "pytest -n auto"
        assert cmds3["typecheck"] == "mypy ."

        # Remove all fields for a language → language entry removed
        resp4 = client.patch(
            "/api/settings",
            json={"project": {"commands": {"python": {"test": "", "typecheck": ""}}}},
        )
        assert resp4.status_code == 200
        assert resp4.json()["project"]["commands"] == {}


def test_project_commands_language_key_normalized(tmp_path: Path) -> None:
    """Uppercase/mixed-case language keys are normalized to lowercase."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        resp = client.patch(
            "/api/settings",
            json={"project": {"commands": {"PYTHON": {"test": "pytest"}, "TypeScript": {"lint": "eslint ."}}}},
        )
        assert resp.status_code == 200
        cmds = resp.json()["project"]["commands"]
        assert "python" in cmds
        assert "typescript" in cmds
        assert "PYTHON" not in cmds
        assert "TypeScript" not in cmds


def test_project_commands_empty_language_key_ignored(tmp_path: Path) -> None:
    """Empty string language key is silently ignored."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        resp = client.patch(
            "/api/settings",
            json={"project": {"commands": {"": {"test": "pytest"}, "python": {"lint": "ruff check ."}}}},
        )
        assert resp.status_code == 200
        cmds = resp.json()["project"]["commands"]
        assert "" not in cmds
        assert cmds["python"]["lint"] == "ruff check ."


def test_project_prompt_overrides_round_trip(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        baseline = client.get("/api/settings")
        assert baseline.status_code == 200
        project = baseline.json()["project"]
        assert project["prompt_overrides"] == {}
        assert project["prompt_injections"] == {}
        assert "implement" in project["prompt_defaults"]

        updated = client.patch(
            "/api/settings",
            json={
                "project": {
                    "prompt_overrides": {
                        "IMPLEMENT": "Custom implement injection",
                        "verify": "Custom verify injection",
                    }
                }
            },
        )
        assert updated.status_code == 200
        overrides = updated.json()["project"]["prompt_overrides"]
        assert overrides["implement"] == "Custom implement injection"
        assert overrides["verify"] == "Custom verify injection"

        removed = client.patch(
            "/api/settings",
            json={"project": {"prompt_overrides": {"verify": ""}}},
        )
        assert removed.status_code == 200
        overrides_after_remove = removed.json()["project"]["prompt_overrides"]
        assert "verify" not in overrides_after_remove
        assert overrides_after_remove["implement"] == "Custom implement injection"

        reloaded = client.get("/api/settings")
        assert reloaded.status_code == 200
        assert reloaded.json()["project"]["prompt_overrides"] == overrides_after_remove


def test_project_prompt_injections_round_trip(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        baseline = client.get("/api/settings")
        assert baseline.status_code == 200
        project = baseline.json()["project"]
        assert project["prompt_injections"] == {}

        updated = client.patch(
            "/api/settings",
            json={
                "project": {
                    "prompt_injections": {
                        "IMPLEMENT": "Preserve public API compatibility.",
                        "verify": "Run smoke tests first.",
                    }
                }
            },
        )
        assert updated.status_code == 200
        injections = updated.json()["project"]["prompt_injections"]
        assert injections["implement"] == "Preserve public API compatibility."
        assert injections["verify"] == "Run smoke tests first."

        removed = client.patch(
            "/api/settings",
            json={"project": {"prompt_injections": {"verify": ""}}},
        )
        assert removed.status_code == 200
        injections_after_remove = removed.json()["project"]["prompt_injections"]
        assert "verify" not in injections_after_remove
        assert injections_after_remove["implement"] == "Preserve public API compatibility."

        reloaded = client.get("/api/settings")
        assert reloaded.status_code == 200
        assert reloaded.json()["project"]["prompt_injections"] == injections_after_remove


def test_claim_lock_prevents_double_claim(tmp_path: Path) -> None:
    container = Container(tmp_path)
    task = Task(title="Concurrent claim", status="queued")
    container.tasks.upsert(task)

    def _claim() -> str | None:
        claimed = container.tasks.claim_next_runnable(max_in_progress=4)
        return claimed.id if claimed else None

    with ThreadPoolExecutor(max_workers=2) as pool:
        outcomes = list(pool.map(lambda _: _claim(), range(2)))

    claimed_ids = [task_id for task_id in outcomes if task_id is not None]
    assert claimed_ids == [task.id]


def test_state_machine_allows_and_rejects_expected_transitions(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "FSM", "status": "backlog"}).json()["task"]
        task_id = task["id"]

        valid = client.post(f"/api/tasks/{task_id}/transition", json={"status": "queued"})
        assert valid.status_code == 200

        invalid = client.post(f"/api/tasks/{task_id}/transition", json={"status": "done"})
        assert invalid.status_code == 400
        assert "Invalid transition" in invalid.text


def test_transition_rejects_blocked_and_in_review_bypass_paths(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        blocked = client.post("/api/tasks", json={"title": "Blocked", "status": "blocked"}).json()["task"]
        blocked_to_review = client.post(
            f"/api/tasks/{blocked['id']}/transition",
            json={"status": "in_review"},
        )
        assert blocked_to_review.status_code == 400
        assert "cannot transition to in_review directly" in str(blocked_to_review.json().get("detail") or "").lower()

        in_review = client.post("/api/tasks", json={"title": "In review", "status": "in_review"}).json()["task"]
        review_to_done = client.post(
            f"/api/tasks/{in_review['id']}/transition",
            json={"status": "done"},
        )
        assert review_to_done.status_code == 400
        assert "cannot transition to done directly" in str(review_to_done.json().get("detail") or "").lower()


def test_delete_terminal_task_cleans_relationship_references(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)

    task_to_delete = Task(title="Terminal delete target", status="done")
    child = Task(title="Child task", status="done", parent_id=task_to_delete.id)
    predecessor = Task(title="Predecessor", status="done", blocks=[task_to_delete.id], children_ids=[task_to_delete.id])
    dependent = Task(title="Dependent", status="done", blocked_by=[task_to_delete.id])
    parent_ref = Task(title="Parent ref", status="done", children_ids=[task_to_delete.id])

    container.tasks.upsert(task_to_delete)
    container.tasks.upsert(child)
    container.tasks.upsert(predecessor)
    container.tasks.upsert(dependent)
    container.tasks.upsert(parent_ref)

    with TestClient(app) as client:
        resp = client.delete(f"/api/tasks/{task_to_delete.id}")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["deleted"] is True
        assert payload["task_id"] == task_to_delete.id
        assert payload["archived_context_count"] == 0
        assert payload["archived_context_manifest_path"] == ""

    fresh = Container(tmp_path)
    assert fresh.tasks.get(task_to_delete.id) is None
    assert fresh.tasks.get(child.id) is not None
    assert fresh.tasks.get(child.id).parent_id is None
    assert task_to_delete.id not in (fresh.tasks.get(predecessor.id).blocks or [])
    assert task_to_delete.id not in (fresh.tasks.get(predecessor.id).children_ids or [])
    assert task_to_delete.id not in (fresh.tasks.get(dependent.id).blocked_by or [])
    assert task_to_delete.id not in (fresh.tasks.get(parent_ref.id).children_ids or [])


def test_delete_non_terminal_task_rejected(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Queued task", "status": "queued"}).json()["task"]
        resp = client.delete(f"/api/tasks/{created['id']}")
        assert resp.status_code == 400
        assert "Only terminal tasks" in resp.json()["detail"]


def test_delete_terminal_task_archives_context_manifest(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)
    retained_dir = tmp_path / ".overdrive" / "worktrees" / "task-delete-context"
    task = Task(
        title="Delete context task",
        status="done",
        metadata={
            "worktree_dir": str(retained_dir),
            "task_context": {
                "context_id": "ctx-delete",
                "worktree_dir": str(retained_dir),
                "task_branch": "task-delete-context",
                "retained": True,
                "expected_on_retry": True,
            },
        },
    )
    container.tasks.upsert(task)

    with TestClient(app) as client:
        resp = client.delete(f"/api/tasks/{task.id}")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["deleted"] is True
        assert payload["archived_context_count"] == 1
        manifest_path = Path(str(payload["archived_context_manifest_path"] or ""))
        assert manifest_path.exists()
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        assert manifest["count"] == 1
        assert manifest["items"][0]["task_id"] == task.id


def test_clear_tasks_archives_state_and_reinitializes_board(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Archive me", "status": "queued"}).json()["task"]

        clear_resp = client.post("/api/tasks/clear")
        assert clear_resp.status_code == 200
        body = clear_resp.json()
        assert body["cleared"] is True
        assert body["archived_context_count"] == 0
        assert body["archived_context_manifest_path"] == ""
        archived_to = str(body["archived_to"] or "")
        assert archived_to
        assert "Archived previous runtime state to" in body["message"]

        archived_path = Path(archived_to)
        assert archived_path.exists()
        assert archived_path.parent == tmp_path / ".overdrive_archive"
        archived_db = archived_path / "runtime.db"
        assert archived_db.exists()
        with sqlite3.connect(archived_db) as conn:
            row = conn.execute("SELECT COUNT(*) FROM tasks WHERE id = ?", (created["id"],)).fetchone()
            assert row is not None
            assert int(row[0]) == 1

        board = client.get("/api/tasks/board")
        assert board.status_code == 200
        columns = board.json()["columns"]
        assert columns["backlog"] == []
        assert columns["queued"] == []
        assert columns["in_progress"] == []
        assert columns["in_review"] == []
        assert columns["blocked"] == []
        assert columns["done"] == []
        assert columns["cancelled"] == []

        fresh_container = Container(tmp_path)
        assert fresh_container.tasks.get(created["id"]) is None
        assert (tmp_path / ".overdrive" / "runtime.db").exists()

        status_resp = client.get("/api/orchestrator/status")
        assert status_resp.status_code == 200
        assert status_resp.json().get("scheduler_attached") is True


def test_clear_tasks_archives_context_manifest_when_present(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)
    retained_dir = tmp_path / ".overdrive" / "worktrees" / "task-retained"
    task = Task(
        title="Retained blocked task",
        status="blocked",
        metadata={
            "worktree_dir": str(retained_dir),
            "task_context": {
                "context_id": "ctx-retained",
                "worktree_dir": str(retained_dir),
                "task_branch": "task-retained",
                "retained": True,
                "expected_on_retry": True,
            },
        },
    )
    container.tasks.upsert(task)

    with TestClient(app) as client:
        clear_resp = client.post("/api/tasks/clear")
        assert clear_resp.status_code == 200
        body = clear_resp.json()
        assert body["cleared"] is True
        assert body["archived_context_count"] == 1
        manifest_path = Path(str(body["archived_context_manifest_path"] or ""))
        assert manifest_path.exists()
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        assert manifest["count"] == 1
        assert manifest["items"][0]["task_id"] == task.id


def test_clear_tasks_rejects_when_active_execution_present_without_force(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Still running", "status": "queued"}).json()["task"]
        key = str(tmp_path.resolve())
        orchestrator = app.state.orchestrators[key]
        stuck_future: Future[object] = Future()
        with orchestrator._futures_lock:
            orchestrator._futures[created["id"]] = stuck_future

        clear_resp = client.post("/api/tasks/clear?timeout_seconds=0")
        assert clear_resp.status_code == 409
        detail = clear_resp.json()["detail"]
        assert "Cannot clear tasks while execution is still active." in str(detail.get("detail") or "")
        assert detail["code"] == "active_execution"
        assert created["id"] in set(detail["data"]["active_execution"]["task_ids"])

        assert Container(tmp_path).tasks.get(created["id"]) is not None
        stuck_future.set_result(None)


def test_clear_tasks_reject_does_not_flip_paused_orchestrator_state(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        pause = client.post("/api/orchestrator/control", json={"action": "pause"})
        assert pause.status_code == 200
        created = client.post("/api/tasks", json={"title": "Paused clear guard", "status": "queued"}).json()["task"]
        key = str(tmp_path.resolve())
        orchestrator = app.state.orchestrators[key]
        stuck_future: Future[object] = Future()
        with orchestrator._futures_lock:
            orchestrator._futures[created["id"]] = stuck_future

        clear_resp = client.post("/api/tasks/clear?timeout_seconds=0")
        assert clear_resp.status_code == 409
        status_resp = client.get("/api/orchestrator/status")
        assert status_resp.status_code == 200
        assert status_resp.json().get("status") == "paused"

        stuck_future.set_result(None)


def test_clear_tasks_force_cancels_in_progress_blockers_and_succeeds(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    container = Container(tmp_path)
    executing = Task(title="Force clear blocker", status="in_progress", pending_gate="before_implement")
    container.tasks.upsert(executing)

    with TestClient(app) as client:
        # Ensure app-owned orchestrator is initialized, then attach an active lease.
        status_resp = client.get("/api/orchestrator/status")
        assert status_resp.status_code == 200
        key = str(tmp_path.resolve())
        orchestrator = app.state.orchestrators[key]
        live = container.tasks.get(executing.id)
        assert live is not None
        orchestrator._acquire_execution_lease(live)

        clear_resp = client.post("/api/tasks/clear?force=true&timeout_seconds=0")
        assert clear_resp.status_code == 200
        body = clear_resp.json()
        assert body["cleared"] is True
        assert body["force"] is True
        assert executing.id in set(body["active_execution_before"]["task_ids"])
        assert body["quiescence_result"]["quiescent"] is True
        assert executing.id in set(body["force_cancel_result"]["cancelled_task_ids"])
        assert Container(tmp_path).tasks.get(executing.id) is None


def test_clear_tasks_force_times_out_when_active_future_persists(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Should remain", "status": "queued"}).json()["task"]
        key = str(tmp_path.resolve())
        orchestrator = app.state.orchestrators[key]
        stuck_future: Future[object] = Future()
        with orchestrator._futures_lock:
            orchestrator._futures["ghost-running"] = stuck_future

        clear_resp = client.post("/api/tasks/clear?force=true&timeout_seconds=0")
        assert clear_resp.status_code == 409
        detail = clear_resp.json()["detail"]
        assert "Forced clear timed out waiting for active execution to stop." in str(detail.get("detail") or "")
        assert detail["code"] == "active_execution_timeout"
        assert "ghost-running" in set(detail["data"]["active_execution"]["task_ids"])
        assert Container(tmp_path).tasks.get(created["id"]) is not None

        stuck_future.set_result(None)


def test_force_cancel_tasks_skips_done_tasks(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        done_task = client.post("/api/tasks", json={"title": "Done task", "status": "done"}).json()["task"]
        key = str(tmp_path.resolve())
        orchestrator = app.state.orchestrators[key]

        result = orchestrator.force_cancel_tasks([done_task["id"]], source="test_force_cancel")
        assert result["cancelled_task_ids"] == []
        assert result["terminal_skipped_task_ids"] == [done_task["id"]]
        assert result["failed"] == {}

        latest = Container(tmp_path).tasks.get(done_task["id"])
        assert latest is not None
        assert latest.status == "done"


def test_api_surfaces_human_blocking_issues_on_task_and_timeline(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Needs credentials",
                "status": "backlog",
                "metadata": {
                    "scripted_steps": {
                        "plan": {
                            "status": "human_blocked",
                            "summary": "Need production API token",
                            "human_blocking_issues": [
                                {
                                    "summary": "Need production API token",
                                    "details": "Grant read-only access",
                                    "action": "Provide token",
                                }
                            ],
                        }
                    }
                },
            },
        ).json()["task"]

        run_resp = client.post(f"/api/tasks/{created['id']}/run")
        assert run_resp.status_code == 200
        task = run_resp.json()["task"]
        assert task["status"] == "blocked"
        assert task["pending_gate"] == "human_intervention"
        assert len(task.get("human_blocking_issues") or []) == 1
        assert task["human_blocking_issues"][0]["summary"] == "Need production API token"

        timeline = client.get(f"/api/collaboration/timeline/{created['id']}")
        assert timeline.status_code == 200
        events = timeline.json()["events"]
        assert any((event.get("human_blocking_issues") or []) for event in events)


def test_retry_clears_pending_gate_and_human_blockers(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Needs credentials",
                "status": "backlog",
                "metadata": {
                    "scripted_steps": {
                        "plan": {
                            "status": "human_blocked",
                            "summary": "Need production API token",
                            "human_blocking_issues": [{"summary": "Need production API token"}],
                        }
                    }
                },
            },
        ).json()["task"]

        run_resp = client.post(f"/api/tasks/{created['id']}/run")
        assert run_resp.status_code == 200
        blocked_task = run_resp.json()["task"]
        assert blocked_task["status"] == "blocked"
        assert blocked_task["pending_gate"] == "human_intervention"
        assert blocked_task.get("human_blocking_issues")

        retry_resp = client.post(f"/api/tasks/{created['id']}/retry")
        assert retry_resp.status_code == 200
        retried_task = retry_resp.json()["task"]
        assert retried_task["status"] == "queued"
        assert retried_task["pending_gate"] is None
        assert retried_task.get("human_blocking_issues") == []

        rerun_resp = client.post(f"/api/tasks/{created['id']}/run")
        assert rerun_resp.status_code == 200


def test_retry_preserves_existing_workdoc_content(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Retry keeps context",
                "status": "backlog",
                "metadata": {
                    "scripted_steps": {
                        "plan": {"status": "ok", "summary": "Original plan context"},
                        "implement": {"status": "error", "summary": "Intentional failure for retry"},
                    }
                },
            },
        ).json()["task"]

        run_resp = client.post(f"/api/tasks/{created['id']}/run")
        assert run_resp.status_code == 200
        blocked_task = run_resp.json()["task"]
        assert blocked_task["status"] == "blocked"

        before = client.get(f"/api/tasks/{created['id']}/workdoc")
        assert before.status_code == 200
        before_content = before.json()["content"]
        assert "Original plan context" in before_content

        retry_resp = client.post(f"/api/tasks/{created['id']}/retry", json={"guidance": "Keep previous plan, continue from verify"})
        assert retry_resp.status_code == 200

        after = client.get(f"/api/tasks/{created['id']}/workdoc")
        assert after.status_code == 200
        after_content = after.json()["content"]
        assert "Original plan context" in after_content
        assert before_content == after_content


def test_retry_run_blocks_when_prior_workdoc_missing(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Retry missing workdoc",
                "status": "backlog",
                "metadata": {
                    "scripted_steps": {
                        "plan": {"status": "ok", "summary": "Plan before failure"},
                        "implement": {"status": "error", "summary": "Fail so retry is available"},
                    }
                },
            },
        ).json()["task"]

        first_run = client.post(f"/api/tasks/{created['id']}/run")
        assert first_run.status_code == 200
        assert first_run.json()["task"]["status"] == "blocked"

        container = Container(tmp_path)
        canonical = container.state_root / "workdocs" / f"{created['id']}.md"
        assert canonical.exists()
        canonical.unlink()
        assert not canonical.exists()

        retry_resp = client.post(f"/api/tasks/{created['id']}/retry")
        assert retry_resp.status_code == 200
        assert retry_resp.json()["task"]["status"] == "queued"

        rerun = client.post(f"/api/tasks/{created['id']}/run")
        assert rerun.status_code == 200
        rerun_task = rerun.json()["task"]
        assert rerun_task["status"] == "blocked"
        assert "Missing required workdoc" in (rerun_task.get("error") or "")


def test_retry_run_blocks_when_prior_workdoc_invalid_encoding(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Retry invalid workdoc",
                "status": "backlog",
                "metadata": {
                    "scripted_steps": {
                        "plan": {"status": "ok", "summary": "Plan before failure"},
                        "implement": {"status": "error", "summary": "Fail so retry is available"},
                    }
                },
            },
        ).json()["task"]

        first_run = client.post(f"/api/tasks/{created['id']}/run")
        assert first_run.status_code == 200
        assert first_run.json()["task"]["status"] == "blocked"

        container = Container(tmp_path)
        canonical = container.state_root / "workdocs" / f"{created['id']}.md"
        assert canonical.exists()
        canonical.write_bytes(b"\xff\xfe\xfa")

        retry_resp = client.post(f"/api/tasks/{created['id']}/retry")
        assert retry_resp.status_code == 200
        assert retry_resp.json()["task"]["status"] == "queued"

        rerun = client.post(f"/api/tasks/{created['id']}/run")
        assert rerun.status_code == 200
        rerun_task = rerun.json()["task"]
        assert rerun_task["status"] == "blocked"
        assert "Invalid workdoc encoding" in (rerun_task.get("error") or "")


def test_retry_clears_stale_invalid_workdoc_markers(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={"title": "Retry clears invalid markers", "status": "backlog"},
        ).json()["task"]
        task_id = created["id"]

        container = Container(tmp_path)
        task = container.tasks.get(task_id)
        assert task is not None
        task.status = "blocked"
        task.metadata["invalid_workdoc_path"] = "/tmp/bad.md"
        task.metadata["invalid_workdoc_error"] = "Invalid workdoc encoding"
        task.metadata["workdoc_sync_error_type"] = "section_id_mismatch"
        task.metadata["workdoc_sync_mode"] = "blocked_invalid_structure"
        task.metadata["workdoc_sync_step"] = "plan"
        task.metadata["workdoc_sync_attempt"] = 3
        container.tasks.upsert(task)

        retry_resp = client.post(f"/api/tasks/{task_id}/retry")
        assert retry_resp.status_code == 200
        payload = retry_resp.json()["task"]
        assert payload["status"] == "queued"
        assert payload["metadata"].get("invalid_workdoc_path") is None
        assert payload["metadata"].get("invalid_workdoc_error") is None
        assert payload["metadata"].get("workdoc_sync_error_type") is None
        assert payload["metadata"].get("workdoc_sync_mode") is None
        assert payload["metadata"].get("workdoc_sync_step") is None
        assert payload["metadata"].get("workdoc_sync_attempt") is None


def test_retry_run_appends_attempt_marker_to_workdoc(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Retry attempt marker",
                "status": "backlog",
                "metadata": {
                    "scripted_steps": {
                        "plan": {"status": "ok", "summary": "Initial attempt plan"},
                        "implement": {"status": "error", "summary": "Force retry"},
                        "verify": {"status": "ok", "summary": "Retry verify completed"},
                    }
                },
            },
        ).json()["task"]

        first_run = client.post(f"/api/tasks/{created['id']}/run")
        assert first_run.status_code == 200
        assert first_run.json()["task"]["status"] == "blocked"

        retry_resp = client.post(
            f"/api/tasks/{created['id']}/retry",
            json={"guidance": "Resume from verify", "start_from_step": "verify"},
        )
        assert retry_resp.status_code == 200

        second_run = client.post(f"/api/tasks/{created['id']}/run")
        assert second_run.status_code == 200
        assert second_run.json()["task"]["status"] == "done"

        workdoc_resp = client.get(f"/api/tasks/{created['id']}/workdoc")
        assert workdoc_resp.status_code == 200
        content = workdoc_resp.json()["content"]
        assert "## Retry Attempt 2" in content
        assert "Resume from verify" in content
        assert "### Attempt 1" in content
        assert "Retry verify completed" in content

        container = Container(tmp_path)
        retry_workdoc_events = [
            event
            for event in container.events.list_recent(limit=2000)
            if event.get("type") == "workdoc.updated"
            and event.get("entity_id") == created["id"]
            and isinstance(event.get("payload"), dict)
            and event["payload"].get("step") == "retry"
        ]
        assert retry_workdoc_events
        latest_payload = retry_workdoc_events[-1]["payload"]
        assert latest_payload["attempt"] == 2
        assert latest_payload["start_from_step"] == "verify"
        assert latest_payload["has_guidance"] is True


def test_retry_event_payload_includes_lifecycle_metadata(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={"title": "Retry event payload", "status": "backlog", },
        ).json()["task"]
        task_id = created["id"]

        # Seed previous error + step so retry metadata is populated.
        container = Container(tmp_path)
        task = container.tasks.get(task_id)
        assert task is not None
        task.status = "blocked"
        task.current_step = "verify"
        task.error = "Verify failed previously"
        container.tasks.upsert(task)

        retry_resp = client.post(
            f"/api/tasks/{task_id}/retry",
            json={"guidance": "Narrow scope and retry verify", "start_from_step": "verify"},
        )
        assert retry_resp.status_code == 200

        events = container.events.list_recent(limit=2000)
        retry_events = [event for event in events if event.get("type") == "task.retry" and event.get("entity_id") == task_id]
        assert retry_events
        payload = retry_events[-1].get("payload")
        assert isinstance(payload, dict)
        assert payload.get("retry_count") == 1
        assert payload.get("start_from_step") == "verify"
        assert payload.get("has_guidance") is True
        assert payload.get("guidance") == "Narrow scope and retry verify"
        assert payload.get("previous_error_present") is True


def test_retry_run_task_started_event_includes_retry_metadata(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Retry started payload",
                "status": "backlog",
                "metadata": {
                    "scripted_steps": {
                        "plan": {"status": "ok", "summary": "Plan one"},
                        "implement": {"status": "error", "summary": "Force retry"},
                        "verify": {"status": "ok", "summary": "Verify after retry"},
                    }
                },
            },
        ).json()["task"]
        task_id = created["id"]

        first = client.post(f"/api/tasks/{task_id}/run")
        assert first.status_code == 200
        assert first.json()["task"]["status"] == "blocked"

        retry_resp = client.post(
            f"/api/tasks/{task_id}/retry",
            json={"guidance": "Continue from verify", "start_from_step": "verify"},
        )
        assert retry_resp.status_code == 200
        retry_metadata = retry_resp.json()["task"].get("metadata") or {}
        active_guidance = retry_metadata.get("active_human_guidance") or {}
        assert active_guidance.get("source") == "retry"
        assert active_guidance.get("target_step") == "verify"
        assert active_guidance.get("guidance") == "Continue from verify"

        second = client.post(f"/api/tasks/{task_id}/run")
        assert second.status_code == 200

        container = Container(tmp_path)
        started_events = [
            event
            for event in container.events.list_recent(limit=2000)
            if event.get("type") == "task.started" and event.get("entity_id") == task_id
        ]
        assert started_events
        payload = started_events[-1].get("payload")
        assert isinstance(payload, dict)
        assert payload.get("is_retry") is True
        assert payload.get("run_attempt") == 2
        assert payload.get("start_from_step") == "verify"
        assert payload.get("has_retry_guidance") is True
        assert payload.get("retry_count") >= 1


def test_review_queue_request_changes_and_approve(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Needs review",
                "status": "backlog",
                "hitl_mode": "review_only",
                "metadata": {"scripted_findings": [[]]},
            },
        ).json()["task"]
        ran = client.post(f"/api/tasks/{task['id']}/run")
        assert ran.status_code == 200
        assert ran.json()["task"]["status"] == "in_review"

        queue = client.get("/api/review-queue").json()
        assert queue["total"] == 1
        assert queue["tasks"][0]["id"] == task["id"]

        request_changes = client.post(
            f"/api/review/{task['id']}/request-changes",
            json={"guidance": "Please adjust tests"},
        )
        assert request_changes.status_code == 200
        assert request_changes.json()["task"]["status"] == "queued"
        actions_after_changes = request_changes.json()["task"]["human_review_actions"]
        assert len(actions_after_changes) == 1
        assert actions_after_changes[0]["action"] == "request_changes"
        assert actions_after_changes[0]["guidance"] == "Please adjust tests"
        container = app.state.containers[str(tmp_path.resolve())]
        task_after_changes = container.tasks.get(task["id"])
        assert task_after_changes is not None
        active_guidance = task_after_changes.metadata.get("active_human_guidance")
        assert isinstance(active_guidance, dict)
        assert active_guidance.get("source") == "review_request_changes"
        assert active_guidance.get("target_step") == "implement"
        assert active_guidance.get("fallback_step") == "implement_fix"
        assert active_guidance.get("guidance") == "Please adjust tests"
        run_after_changes = container.runs.get(task_after_changes.run_ids[-1]) if task_after_changes.run_ids else None
        assert run_after_changes is not None
        assert run_after_changes.status == "interrupted"

        client.post(f"/api/tasks/{task['id']}/run")
        approved = client.post(f"/api/review/{task['id']}/approve", json={})
        assert approved.status_code == 200
        assert approved.json()["task"]["status"] == "queued"
        actions_after_approve = approved.json()["task"]["human_review_actions"]
        assert len(actions_after_approve) == 2
        assert actions_after_approve[0]["action"] == "request_changes"
        assert actions_after_approve[1]["action"] == "approve"

        reran = client.post(f"/api/tasks/{task['id']}/run")
        assert reran.status_code == 200
        assert reran.json()["task"]["status"] == "done"

        latest_run = None
        task_after = container.tasks.get(task["id"])
        assert task_after is not None
        for run_id in reversed(task_after.run_ids):
            latest_run = container.runs.get(run_id)
            if latest_run:
                break
        assert latest_run is not None
        assert latest_run.status == "done"
        assert latest_run.finished_at is not None


def test_review_approve_rejects_blocked_task_status(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={"title": "Blocked review approve", "status": "blocked"},
        ).json()["task"]
        resp = client.post(f"/api/review/{task['id']}/approve", json={})
        assert resp.status_code == 400
        assert "not in_review" in str(resp.json().get("detail") or "")


def test_review_approve_blocks_on_dirty_overlapping_merge_failure(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Dirty overlap on approve"}).json()["task"]
        task_branch = f"task-{task['id']}"
        _git(["checkout", "-b", task_branch], tmp_path)
        (tmp_path / "tracked.txt").write_text("task branch change\n", encoding="utf-8")
        _git(["add", "tracked.txt"], tmp_path)
        _git(
            [
                "-c",
                "user.name=AO Test",
                "-c",
                "user.email=ao-test@example.com",
                "commit",
                "-m",
                "task branch commit",
            ],
            tmp_path,
        )
        _git(["checkout", base_branch], tmp_path)
        # Overlapping local dirty edit should block merge.
        (tmp_path / "tracked.txt").write_text("local dirty change\n", encoding="utf-8")

        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        stored.status = "in_review"
        stored.current_step = "review"
        stored.metadata["preserved_branch"] = task_branch
        container.tasks.upsert(stored)

        resp = client.post(f"/api/review/{task['id']}/approve", json={})
        assert resp.status_code == 200
        payload = resp.json()["task"]
        assert payload["status"] == "blocked"
        assert payload["current_step"] == "commit"
        assert "local changes" in str(payload.get("error") or "").lower()
        assert payload["metadata"].get("merge_failure_reason_code") == "dirty_overlapping"
        assert payload["can_finalize_merge_conflict"] is False
        assert payload["finalize_merge_conflict_reason_code"] == "not_merge_conflict_block"
        updated = container.tasks.get(task["id"])
        assert updated is not None
        latest_run = None
        for run_id in reversed(updated.run_ids):
            latest_run = container.runs.get(run_id)
            if latest_run:
                break
        if latest_run is not None:
            assert "overlapping local integration changes" in str(latest_run.summary or "").lower()


def test_task_payload_surfaces_skip_to_precommit_eligibility(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        feature = client.post("/api/tasks", json={"title": "Eligible blocked skip"}).json()["task"]
        feature_branch = f"task-{feature['id']}"
        _git(["checkout", "-b", feature_branch], tmp_path)
        (tmp_path / "tracked.txt").write_text("base\nfeature change\n", encoding="utf-8")
        _git(["add", "tracked.txt"], tmp_path)
        _git(
            [
                "-c",
                "user.name=AO Test",
                "-c",
                "user.email=ao-test@example.com",
                "commit",
                "-m",
                "feature work",
            ],
            tmp_path,
        )
        _git(["checkout", base_branch], tmp_path)

        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(feature["id"])
        assert stored is not None
        stored.status = "blocked"
        stored.current_step = "verify"
        stored.metadata["preserved_branch"] = feature_branch
        stored.error = "verify failed because local setup is incomplete"
        container.tasks.upsert(stored)

        payload = client.get(f"/api/tasks/{feature['id']}").json()["task"]
        assert payload["can_skip_to_precommit"] is True
        assert payload["skip_to_precommit_reason_code"] is None

        research = client.post(
            "/api/tasks",
            json={"title": "Research blocked", "status": "blocked", "task_type": "research"},
        ).json()["task"]
        research_payload = client.get(f"/api/tasks/{research['id']}").json()["task"]
        assert research_payload["can_skip_to_precommit"] is False
        assert research_payload["skip_to_precommit_reason_code"] == "pipeline_not_supported"


def test_skip_to_precommit_endpoint_moves_task_to_precommit_review(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Skip to precommit path"}).json()["task"]
        task_branch = f"task-{task['id']}"
        _git(["checkout", "-b", task_branch], tmp_path)
        (tmp_path / "tracked.txt").write_text("base\nskip-to-precommit change\n", encoding="utf-8")
        _git(["add", "tracked.txt"], tmp_path)
        _git(
            [
                "-c",
                "user.name=AO Test",
                "-c",
                "user.email=ao-test@example.com",
                "commit",
                "-m",
                "task work",
            ],
            tmp_path,
        )
        _git(["checkout", base_branch], tmp_path)

        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        stored.status = "blocked"
        stored.current_step = "verify"
        stored.error = "verify failed due missing env dependency"
        stored.metadata["preserved_branch"] = task_branch
        container.tasks.upsert(stored)

        resp = client.post(
            f"/api/tasks/{task['id']}/skip-to-precommit",
            json={"guidance": "Accept risk and continue to commit"},
        )
        assert resp.status_code == 200
        payload = resp.json()["task"]
        assert payload["status"] == "in_review"
        assert payload["current_step"] == "review"
        assert payload["pending_gate"] is None
        assert payload["metadata"]["pending_precommit_approval"] is True
        assert payload["metadata"]["review_stage"] == "pre_commit"
        assert payload["metadata"]["retry_from_step"] == "commit"
        assert payload["can_skip_to_precommit"] is False
        assert payload["skip_to_precommit_reason_code"] == "task_not_blocked"
        actions = payload.get("human_review_actions") or []
        assert actions
        assert actions[-1]["action"] == "skip_to_precommit"
        assert actions[-1]["guidance"] == "Accept risk and continue to commit"

        approve = client.post(f"/api/review/{task['id']}/approve", json={})
        assert approve.status_code == 200
        assert approve.json()["task"]["status"] == "queued"


def test_skip_to_precommit_endpoint_rejects_ineligible_task(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={"title": "Research blocked", "status": "blocked", "task_type": "research"},
        ).json()["task"]
        resp = client.post(f"/api/tasks/{task['id']}/skip-to-precommit", json={})
        assert resp.status_code == 409
        detail = str(resp.json().get("detail") or "")
        assert "pipeline_not_supported" in detail


def test_skip_to_precommit_eligibility_rejects_no_material_preserved_work(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "No preserved changes"}).json()["task"]
        preserved_branch = f"task-{task['id']}"
        _git(["checkout", "-b", preserved_branch], tmp_path)
        # No changes committed on preserved branch.
        _git(["checkout", base_branch], tmp_path)

        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        stored.status = "blocked"
        stored.current_step = "verify"
        stored.error = "verify failed in environment setup"
        stored.metadata["preserved_branch"] = preserved_branch
        container.tasks.upsert(stored)

        payload = client.get(f"/api/tasks/{task['id']}").json()["task"]
        assert payload["can_skip_to_precommit"] is False
        assert payload["skip_to_precommit_reason_code"] == "no_task_changes"

        resp = client.post(f"/api/tasks/{task['id']}/skip-to-precommit", json={})
        assert resp.status_code == 409
        assert "no_task_changes" in str(resp.json().get("detail") or "")


def test_task_payload_surfaces_finalize_merge_conflict_eligibility(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Manual merge finalize eligible"}).json()["task"]
        task_branch = f"task-{task['id']}"
        _git(["checkout", "-b", task_branch], tmp_path)
        (tmp_path / "tracked.txt").write_text("base\nmanual merge content\n", encoding="utf-8")
        _git(["add", "tracked.txt"], tmp_path)
        _git(
            [
                "-c",
                "user.name=AO Test",
                "-c",
                "user.email=ao-test@example.com",
                "commit",
                "-m",
                "task commit",
            ],
            tmp_path,
        )
        _git(["checkout", base_branch], tmp_path)
        _git(["merge", "--ff", task_branch], tmp_path)

        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        stored.status = "blocked"
        stored.current_step = "commit"
        stored.metadata["merge_conflict"] = True
        stored.metadata["preserved_branch"] = task_branch
        container.tasks.upsert(stored)

        payload = client.get(f"/api/tasks/{task['id']}").json()["task"]
        assert payload["can_finalize_merge_conflict"] is True
        assert payload["finalize_merge_conflict_reason_code"] is None


def test_finalize_merge_conflict_endpoint_marks_task_done(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Finalize manual merge"}).json()["task"]
        task_branch = f"task-{task['id']}"
        _git(["checkout", "-b", task_branch], tmp_path)
        (tmp_path / "tracked.txt").write_text("base\nmanual finalize\n", encoding="utf-8")
        _git(["add", "tracked.txt"], tmp_path)
        _git(
            [
                "-c",
                "user.name=AO Test",
                "-c",
                "user.email=ao-test@example.com",
                "commit",
                "-m",
                "task commit",
            ],
            tmp_path,
        )
        branch_sha = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=tmp_path,
            check=True,
            capture_output=True,
            text=True,
        ).stdout.strip()
        _git(["checkout", base_branch], tmp_path)
        _git(["merge", "--ff", task_branch], tmp_path)

        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        run = RunRecord(
            task_id=stored.id,
            status="in_progress",
            started_at=now_iso(),
            steps=[{"step": "commit", "status": "ok", "commit": branch_sha}],
        )
        container.runs.upsert(run)
        stored.run_ids.append(run.id)
        stored.status = "blocked"
        stored.current_step = "commit"
        stored.error = "Merge conflict could not be resolved automatically"
        stored.metadata["merge_conflict"] = True
        stored.metadata["preserved_branch"] = task_branch
        stored.metadata["merge_conflict_files"] = {"tracked.txt": "<<<<<<< ours"}
        stored.metadata["merge_conflict_attempt"] = 2
        stored.metadata["merge_conflict_max_attempts"] = 3
        stored.metadata["merge_conflict_previous_error"] = "worker failed"
        container.tasks.upsert(stored)

        resp = client.post(
            f"/api/tasks/{task['id']}/finalize-merge-conflict",
            json={"guidance": "Resolved manually in git."},
        )
        assert resp.status_code == 200
        payload = resp.json()["task"]
        assert payload["status"] == "done"
        assert payload["current_step"] is None
        assert payload["error"] is None
        for key in (
            "merge_conflict",
            "merge_conflict_files",
            "merge_conflict_attempt",
            "merge_conflict_max_attempts",
            "merge_conflict_previous_error",
            "pending_precommit_approval",
            "review_stage",
            "pipeline_phase",
        ):
            assert key not in payload["metadata"]
        actions = payload.get("human_review_actions") or []
        assert actions and actions[-1]["action"] == "finalize_merge_conflict"
        assert actions[-1]["guidance"] == "Resolved manually in git."
        assert payload["metadata"]["last_manual_merge_finalize"]["verified_ref"] == task_branch

        latest_run = container.runs.get(run.id)
        assert latest_run is not None
        assert latest_run.status == "done"
        assert latest_run.finished_at is not None


def test_finalize_merge_conflict_endpoint_rejects_unintegrated_and_invalid_state(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Not integrated"}).json()["task"]
        task_branch = f"task-{task['id']}"
        _git(["checkout", "-b", task_branch], tmp_path)
        (tmp_path / "tracked.txt").write_text("base\nbranch only\n", encoding="utf-8")
        _git(["add", "tracked.txt"], tmp_path)
        _git(
            [
                "-c",
                "user.name=AO Test",
                "-c",
                "user.email=ao-test@example.com",
                "commit",
                "-m",
                "branch commit",
            ],
            tmp_path,
        )
        _git(["checkout", base_branch], tmp_path)

        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        stored.status = "blocked"
        stored.current_step = "commit"
        stored.metadata["merge_conflict"] = True
        stored.metadata["preserved_branch"] = task_branch
        container.tasks.upsert(stored)

        not_integrated = client.post(f"/api/tasks/{task['id']}/finalize-merge-conflict", json={})
        assert not_integrated.status_code == 409
        assert "merge_not_integrated" in str(not_integrated.json().get("detail") or "")

        stored = container.tasks.get(task["id"])
        assert stored is not None
        stored.status = "queued"
        container.tasks.upsert(stored)
        wrong_state = client.post(f"/api/tasks/{task['id']}/finalize-merge-conflict", json={})
        assert wrong_state.status_code == 409
        assert "task_not_blocked" in str(wrong_state.json().get("detail") or "")


def test_finalize_merge_conflict_endpoint_rejects_unmerged_index_entries(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Unmerged index"}).json()["task"]
        task_branch = f"task-{task['id']}"
        _git(["checkout", "-b", task_branch], tmp_path)
        (tmp_path / "tracked.txt").write_text("task branch change\n", encoding="utf-8")
        _git(["add", "tracked.txt"], tmp_path)
        _git(
            [
                "-c",
                "user.name=AO Test",
                "-c",
                "user.email=ao-test@example.com",
                "commit",
                "-m",
                "task branch commit",
            ],
            tmp_path,
        )
        _git(["checkout", base_branch], tmp_path)
        (tmp_path / "tracked.txt").write_text("base branch change\n", encoding="utf-8")
        _git(["add", "tracked.txt"], tmp_path)
        _git(
            [
                "-c",
                "user.name=AO Test",
                "-c",
                "user.email=ao-test@example.com",
                "commit",
                "-m",
                "base branch commit",
            ],
            tmp_path,
        )
        merge_result = subprocess.run(
            [
                "git",
                "-c", "user.name=AO Test",
                "-c", "user.email=ao-test@example.com",
                "merge", task_branch,
            ],
            cwd=tmp_path,
            capture_output=True,
            text=True,
            check=False,
        )
        assert merge_result.returncode == 1, (
            f"Expected conflict exit code 1, got {merge_result.returncode}: "
            f"{merge_result.stderr}"
        )
        # Verify the index actually has unmerged entries
        ls_unmerged = subprocess.run(
            ["git", "ls-files", "--unmerged"],
            cwd=tmp_path,
            capture_output=True,
            text=True,
            check=False,
        )
        assert ls_unmerged.stdout.strip(), (
            f"Expected unmerged index entries but got none. "
            f"Merge stderr: {merge_result.stderr}"
        )

        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        stored.status = "blocked"
        stored.current_step = "commit"
        stored.metadata["merge_conflict"] = True
        stored.metadata["preserved_branch"] = task_branch
        container.tasks.upsert(stored)

        resp = client.post(f"/api/tasks/{task['id']}/finalize-merge-conflict", json={})
        assert resp.status_code == 409
        assert "unmerged_entries_present" in str(resp.json().get("detail") or "")


def test_precommit_review_approve_rejects_missing_context(tmp_path: Path) -> None:
    _git(["init"], tmp_path)

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        client.get("/api/tasks")
        container = app.state.containers[str(tmp_path.resolve())]
        task = Task(
            title="Missing precommit context",
            status="in_review",
            task_type="feature",
            hitl_mode="review_only",
            metadata={"pending_precommit_approval": True, "review_stage": "pre_commit"},
        )
        container.tasks.upsert(task)

        resp = client.post(f"/api/review/{task.id}/approve", json={})
        assert resp.status_code == 409
        assert "Pre-commit context missing" in str(resp.json().get("detail") or "")


def test_precommit_review_approve_accepts_sha_backed_review_context(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    _git(["checkout", "-b", "orchestrator-run-test"], tmp_path)
    base_sha = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    preserved_branch = "task-precommit-ctx"
    _git(["checkout", "-b", preserved_branch], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\nupdated\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "preserved work",
        ],
        tmp_path,
    )
    head_sha = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    fingerprint = hashlib.sha256(
        subprocess.run(
            ["git", "diff", "--binary", "--no-color", f"{base_sha}..{head_sha}"],
            cwd=tmp_path,
            check=True,
            capture_output=True,
            text=True,
        ).stdout.encode("utf-8")
    ).hexdigest()
    _git(["checkout", "orchestrator-run-test"], tmp_path)

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        client.get("/api/tasks")
        container = app.state.containers[str(tmp_path.resolve())]
        task = Task(
            title="Valid precommit context",
            status="in_review",
            task_type="feature",
            hitl_mode="review_only",
            metadata={
                "pending_precommit_approval": True,
                "review_stage": "pre_commit",
                "preserved_branch": preserved_branch,
                "review_context": {
                    "preserved_branch": preserved_branch,
                    "base_branch": "orchestrator-run-test",
                    "base_sha": base_sha,
                    "head_sha": head_sha,
                    "diff_fingerprint": fingerprint,
                },
            },
        )
        container.tasks.upsert(task)

        resp = client.post(f"/api/review/{task.id}/approve", json={})
        assert resp.status_code == 200
        payload = resp.json()["task"]
        assert payload["status"] == "queued"


def test_task_changes_endpoint_handles_unborn_head(tmp_path: Path) -> None:
    _git(["init"], tmp_path)

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Unborn head changes"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        metadata = dict(stored.metadata or {})
        metadata["worktree_dir"] = str(tmp_path)
        stored.metadata = metadata
        container.tasks.upsert(stored)
        (tmp_path / "new-file.txt").write_text("draft\n", encoding="utf-8")

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "working_tree"
        assert payload["commit"] is None
        assert any(item.get("path") == "new-file.txt" for item in payload["files"])
        assert "?? new-file.txt" in payload["stat"]


def test_task_changes_endpoint_ignores_out_of_scope_worktree_dir(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "init",
        ],
        tmp_path,
    )

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Scoped changes"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None

        outside = tmp_path.parent / "outside-worktree"
        outside.mkdir(parents=True, exist_ok=True)
        metadata = dict(stored.metadata or {})
        metadata["worktree_dir"] = str(outside)
        stored.metadata = metadata
        container.tasks.upsert(stored)

        (tmp_path / "tracked.txt").write_text("base\nupdate\n", encoding="utf-8")

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "none"
        assert payload["reason"] == "invalid_worktree_context"
        assert payload["commit"] is None
        assert payload["files"] == []
        assert payload["diff"] == ""


def test_task_changes_endpoint_rejects_blocked_repo_root_worktree_context(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "init",
        ],
        tmp_path,
    )
    (tmp_path / "tracked.txt").write_text("base\nrepo root change\n", encoding="utf-8")

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Blocked root context"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        stored.status = "blocked"
        stored.metadata["worktree_dir"] = str(tmp_path)
        stored.metadata["task_context"] = {
            "context_id": "ctx-root",
            "worktree_dir": str(tmp_path),
            "task_branch": f"task-{stored.id}",
            "retained": True,
            "expected_on_retry": True,
        }
        container.tasks.upsert(stored)

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "none"
        assert payload["reason"] == "invalid_worktree_context"
        assert payload["diff"] == ""


def test_task_changes_endpoint_does_not_fallback_to_repo_root_without_context(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "init",
        ],
        tmp_path,
    )

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "No context"}).json()["task"]
        (tmp_path / "tracked.txt").write_text("base\nunrelated root change\n", encoding="utf-8")

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "none"
        assert payload["reason"] == "task_context_missing"
        assert payload["commit"] is None
        assert payload["files"] == []
        assert payload["diff"] == ""


def test_task_changes_endpoint_falls_back_when_commit_diff_errors(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "init",
        ],
        tmp_path,
    )

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Fallback changes"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        metadata = dict(stored.metadata or {})
        metadata["worktree_dir"] = str(tmp_path)
        stored.metadata = metadata

        bad_run = RunRecord(
            task_id=stored.id,
            status="done",
            started_at=now_iso(),
            finished_at=now_iso(),
            steps=[{"step": "commit", "status": "ok", "commit": "deadbeef"}],
        )
        container.runs.upsert(bad_run)
        stored.run_ids.append(bad_run.id)
        container.tasks.upsert(stored)

        (tmp_path / "tracked.txt").write_text("base\nupdate\n", encoding="utf-8")

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "working_tree"
        assert payload["context_source"] == "retained_worktree"
        assert payload["commit"] is None
        assert any(item.get("path") == "tracked.txt" for item in payload["files"])
        assert "+update" in payload["diff"]


def test_task_changes_endpoint_prefers_blocked_retained_worktree_over_commit_diff(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "init",
        ],
        tmp_path,
    )

    old_commit = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Blocked retained changes"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        retained_branch = f"task-{stored.id}"
        retained_worktree = container.state_root / "worktrees" / stored.id
        subprocess.run(
            ["git", "worktree", "add", str(retained_worktree), "-b", retained_branch],
            cwd=tmp_path,
            check=True,
            capture_output=True,
            text=True,
        )
        (retained_worktree / "tracked.txt").write_text("base\nold commit change\nnew retained change\n", encoding="utf-8")
        _git(["add", "tracked.txt"], retained_worktree)
        _git(
            [
                "-c",
                "user.name=AO Test",
                "-c",
                "user.email=ao-test@example.com",
                "commit",
                "-m",
                "retained work",
            ],
            retained_worktree,
        )
        stored.status = "blocked"
        stored.metadata["worktree_dir"] = str(retained_worktree)
        stored.metadata["task_context"] = {
            "context_id": "ctx-retained",
            "worktree_dir": str(retained_worktree),
            "task_branch": retained_branch,
            "base_branch": base_branch,
            "retained": True,
            "expected_on_retry": True,
        }
        run = RunRecord(
            task_id=stored.id,
            status="done",
            started_at=now_iso(),
            finished_at=now_iso(),
            steps=[{"step": "commit", "status": "ok", "commit": old_commit}],
        )
        container.runs.upsert(run)
        stored.run_ids.append(run.id)
        container.tasks.upsert(stored)

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "working_tree"
        assert payload["context_source"] == "retained_worktree"
        assert payload["base_ref"] == base_branch
        assert "+new retained change" in payload["diff"]


def test_task_changes_endpoint_uses_preserved_branch_when_worktree_is_empty(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / ".gitignore").write_text(
        ".overdrive/\n\n# Overdrive runtime data\n.overdrive_archive/\n.workdoc.md\n",
        encoding="utf-8",
    )
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", ".gitignore", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )

    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    preserved_branch = "task-task-preserved"
    _git(["checkout", "-b", preserved_branch], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\nbranch change\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "branch work",
        ],
        tmp_path,
    )
    _git(["checkout", base_branch], tmp_path)

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Preserved branch changes"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        metadata = dict(stored.metadata or {})
        metadata["preserved_branch"] = preserved_branch
        stored.metadata = metadata
        container.tasks.upsert(stored)

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "preserved_branch"
        assert payload["commit"] is None
        assert payload["branch"] == preserved_branch
        assert payload["base_branch"] == base_branch
        assert payload["base_source"] == "heuristic"
        assert payload["confidence"] == "low"
        assert payload["base_sha"]
        assert payload["head_sha"]
        assert payload["head_ref"] == preserved_branch
        assert isinstance(payload["warnings"], list)
        assert any(item.get("path") == "tracked.txt" for item in payload["files"])
        assert "+branch change" in payload["diff"]


def test_task_changes_endpoint_prefers_preserved_branch_without_task_worktree_context(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / ".gitignore").write_text(
        ".overdrive/\n\n# Overdrive runtime data\n.overdrive_archive/\n.workdoc.md\n",
        encoding="utf-8",
    )
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", ".gitignore", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )

    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    preserved_branch = "task-task-preserved"
    _git(["checkout", "-b", preserved_branch], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\nbranch change\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "branch work",
        ],
        tmp_path,
    )
    _git(["checkout", base_branch], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\nworking change\n", encoding="utf-8")

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Prefer preserved branch"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        metadata = dict(stored.metadata or {})
        metadata["preserved_branch"] = preserved_branch
        stored.metadata = metadata
        container.tasks.upsert(stored)

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "preserved_branch"
        assert payload["commit"] is None
        assert payload["branch"] == preserved_branch
        assert payload["base_branch"] == base_branch
        assert payload["base_source"] == "heuristic"
        assert payload["confidence"] == "low"
        assert payload["base_sha"]
        assert payload["head_sha"]
        assert payload["head_ref"] == preserved_branch
        assert isinstance(payload["warnings"], list)
        assert any(item.get("path") == "tracked.txt" for item in payload["files"])
        assert "+branch change" in payload["diff"]
        assert "+working change" not in payload["diff"]


def test_task_changes_endpoint_prefers_metadata_sha_anchor_over_heuristic(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    _git(["checkout", "-b", "orchestrator-run-test"], tmp_path)
    (tmp_path / "run-only.txt").write_text("run only\n", encoding="utf-8")
    _git(["add", "run-only.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "run branch commit",
        ],
        tmp_path,
    )
    base_sha = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    preserved_branch = "task-task-preserved"
    _git(["checkout", "-b", preserved_branch], tmp_path)
    (tmp_path / "task-only.txt").write_text("task only\n", encoding="utf-8")
    _git(["add", "task-only.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "task branch commit",
        ],
        tmp_path,
    )
    head_sha = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    _git(["checkout", base_branch], tmp_path)

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Metadata anchored changes"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        metadata = dict(stored.metadata or {})
        metadata["preserved_branch"] = preserved_branch
        metadata["preserved_base_branch"] = "orchestrator-run-test"
        metadata["preserved_base_sha"] = base_sha
        metadata["preserved_head_sha"] = head_sha
        stored.metadata = metadata
        container.tasks.upsert(stored)

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "preserved_branch"
        assert payload["base_source"] == "metadata_sha"
        assert payload["confidence"] == "high"
        assert payload["base_ref"] == "orchestrator-run-test"
        assert payload["base_sha"] == base_sha
        assert payload["head_sha"] == head_sha
        assert "+task only" in payload["diff"]
        assert "run-only.txt" not in payload["diff"]


def test_task_changes_endpoint_uses_review_context_anchor_for_legacy_preserved_branch(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    _git(["checkout", "-b", "orchestrator-run-test"], tmp_path)
    (tmp_path / "run-only.txt").write_text("run only\n", encoding="utf-8")
    _git(["add", "run-only.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "run branch commit",
        ],
        tmp_path,
    )
    base_sha = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    preserved_branch = "task-task-preserved"
    _git(["checkout", "-b", preserved_branch], tmp_path)
    (tmp_path / "task-only.txt").write_text("task only\n", encoding="utf-8")
    _git(["add", "task-only.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "task branch commit",
        ],
        tmp_path,
    )
    head_sha = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    _git(["checkout", base_branch], tmp_path)

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Review-context anchored changes"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        metadata = dict(stored.metadata or {})
        metadata["preserved_branch"] = preserved_branch
        metadata["review_context"] = {
            "preserved_branch": preserved_branch,
            "base_branch": "orchestrator-run-test",
            "base_sha": base_sha,
            "head_sha": head_sha,
        }
        stored.metadata = metadata
        container.tasks.upsert(stored)

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "preserved_branch"
        assert payload["base_source"] == "review_context_sha"
        assert payload["confidence"] == "medium"
        assert payload["base_ref"] == "orchestrator-run-test"
        assert payload["base_sha"] == base_sha
        assert payload["head_sha"] == head_sha
        assert "+task only" in payload["diff"]
        assert "run-only.txt" not in payload["diff"]


def test_task_changes_endpoint_falls_back_from_stale_metadata_branch_to_review_context_sha(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    _git(["checkout", "-b", "orchestrator-run-test"], tmp_path)
    (tmp_path / "run-only.txt").write_text("run only\n", encoding="utf-8")
    _git(["add", "run-only.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "run branch commit",
        ],
        tmp_path,
    )
    base_sha = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()

    preserved_branch = "task-task-preserved"
    _git(["checkout", "-b", preserved_branch], tmp_path)
    (tmp_path / "task-only.txt").write_text("task only\n", encoding="utf-8")
    _git(["add", "task-only.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "task branch commit",
        ],
        tmp_path,
    )
    head_sha = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    _git(["checkout", base_branch], tmp_path)

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Stale metadata branch fallback"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        metadata = dict(stored.metadata or {})
        metadata["preserved_branch"] = preserved_branch
        metadata["preserved_base_branch"] = "nonexistent-base-branch"
        metadata["review_context"] = {
            "preserved_branch": preserved_branch,
            "base_branch": "orchestrator-run-test",
            "base_sha": base_sha,
            "head_sha": head_sha,
        }
        stored.metadata = metadata
        container.tasks.upsert(stored)

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "preserved_branch"
        assert payload["base_source"] == "review_context_sha"
        assert payload["confidence"] == "medium"
        assert payload["base_ref"] == "orchestrator-run-test"
        assert payload["base_sha"] == base_sha
        assert payload["head_sha"] == head_sha
        assert "+task only" in payload["diff"]
        assert "run-only.txt" not in payload["diff"]


def test_task_changes_endpoint_reports_low_confidence_warning_for_suspicious_heuristic_diff(tmp_path: Path) -> None:
    _git(["init"], tmp_path)
    (tmp_path / "tracked.txt").write_text("base\n", encoding="utf-8")
    _git(["add", "tracked.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "base",
        ],
        tmp_path,
    )
    base_branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=tmp_path,
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    _git(["checkout", "-b", "orchestrator-run-test"], tmp_path)

    for index in range(130):
        path = tmp_path / f"large-{index:03d}.txt"
        path.write_text(f"value {index}\n", encoding="utf-8")
    _git(["add", "."], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "large heuristic diff",
        ],
        tmp_path,
    )
    preserved_branch = "task-task-preserved"
    _git(["checkout", "-b", preserved_branch], tmp_path)
    (tmp_path / "task-only.txt").write_text("task only\n", encoding="utf-8")
    _git(["add", "task-only.txt"], tmp_path)
    _git(
        [
            "-c",
            "user.name=AO Test",
            "-c",
            "user.email=ao-test@example.com",
            "commit",
            "-m",
            "task branch commit",
        ],
        tmp_path,
    )
    _git(["checkout", base_branch], tmp_path)

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Suspicious heuristic diff"}).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        stored = container.tasks.get(task["id"])
        assert stored is not None
        metadata = dict(stored.metadata or {})
        metadata["preserved_branch"] = preserved_branch
        stored.metadata = metadata
        container.tasks.upsert(stored)

        resp = client.get(f"/api/tasks/{task['id']}/changes")
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["mode"] == "preserved_branch"
        assert payload["base_source"] == "heuristic"
        assert payload["confidence"] == "low"
        assert isinstance(payload["warnings"], list)
        assert "heuristic_base_inferred" in payload["warnings"]
        assert "large_file_count" in payload["warnings"]


def test_task_execution_summary_prefers_terminal_run_for_done_task(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={"title": "Summary run selection", "status": "backlog", },
        ).json()["task"]
        run_resp = client.post(f"/api/tasks/{created['id']}/run")
        assert run_resp.status_code == 200
        assert run_resp.json()["task"]["status"] == "done"

        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None
        assert task.run_ids
        completed_run = container.runs.get(task.run_ids[-1])
        assert completed_run is not None
        assert completed_run.status == "done"

        stale_run = RunRecord(
            task_id=task.id,
            status="in_progress",
            started_at=now_iso(),
            finished_at=None,
            summary="Stale in-progress run",
            steps=[{"step": "implement", "status": "ok", "summary": "stale step", "started_at": now_iso(), "ts": now_iso()}],
        )
        container.runs.upsert(stale_run)
        task.run_ids.append(stale_run.id)
        container.tasks.upsert(task)

        task_detail = client.get(f"/api/tasks/{task.id}")
        assert task_detail.status_code == 200
        execution_summary = task_detail.json()["task"].get("execution_summary") or {}
        assert execution_summary.get("run_id") == completed_run.id
        assert execution_summary.get("run_status") == "done"


def test_task_execution_summary_reconciles_single_stale_run_for_done_task(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={"title": "Single stale run", "status": "done", },
        ).json()["task"]

        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None

        stale_run = RunRecord(
            task_id=task.id,
            status="in_progress",
            started_at=now_iso(),
            finished_at=None,
            summary="Stale in-progress run",
            steps=[{"step": "implement", "status": "ok", "summary": "stale step", "started_at": now_iso(), "ts": now_iso()}],
        )
        container.runs.upsert(stale_run)
        task.status = "done"
        task.run_ids = [stale_run.id]
        container.tasks.upsert(task)

        task_detail = client.get(f"/api/tasks/{task.id}")
        assert task_detail.status_code == 200
        execution_summary = task_detail.json()["task"].get("execution_summary") or {}
        assert execution_summary.get("run_id") == stale_run.id
        assert execution_summary.get("run_status") == "done"


def test_get_task_plan(tmp_path: Path) -> None:
    """GET /api/tasks/{id}/plan returns revision-based plan history."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Plan query test"}).json()["task"]
        first_revision_id = _create_plan_revision(client, task["id"], "Plan A")
        _create_plan_revision(client, task["id"], "Plan B", parent_revision_id=first_revision_id)

        resp = client.get(f"/api/tasks/{task['id']}/plan")
        assert resp.status_code == 200
        body = resp.json()
        assert len(body["revisions"]) == 2
        assert body["latest_revision_id"] == body["revisions"][-1]["id"]
        assert body["revisions"][-1]["content"] == "Plan B"

        # Non-existent task → 404
        resp_404 = client.get("/api/tasks/nonexistent/plan")
        assert resp_404.status_code == 404

        # Task with no revisions → empty list
        task_no_plan = client.post("/api/tasks", json={"title": "No plan"}).json()["task"]
        resp_empty = client.get(f"/api/tasks/{task_no_plan['id']}/plan")
        assert resp_empty.status_code == 200
        assert resp_empty.json()["revisions"] == []
        assert resp_empty.json()["latest_revision_id"] is None


def test_generate_tasks_endpoint(tmp_path: Path) -> None:
    """POST /api/tasks/{id}/generate-tasks creates child tasks from stored plan."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        # Create a task with a plan and scripted generation output
        task = client.post(
            "/api/tasks",
            json={
                "title": "Generate from plan",
                "task_type": "initiative_plan",
                "status": "backlog",
                "metadata": {
                    "scripted_generated_tasks": [
                        {"title": "Login endpoint", "task_type": "feature", "priority": "P1"},
                        {"title": "Session store", "task_type": "feature", "priority": "P2"},
                    ],
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Build auth")

        resp = client.post(f"/api/tasks/{task['id']}/generate-tasks", json={})
        assert resp.status_code == 200
        body = resp.json()
        assert len(body["created_task_ids"]) == 2
        assert len(body["children"]) == 2
        assert body["children"][0]["title"] == "Login endpoint"
        assert body["children"][1]["title"] == "Session store"
        assert body["task"]["children_ids"] == body["created_task_ids"]
        assert body["effective_policy"]["child_status"] == "backlog"
        assert body["effective_policy"]["child_hitl_mode"] == "autopilot"
        assert body["effective_policy"]["infer_deps"] is True


def test_generate_tasks_endpoint_normalizes_generated_task_types(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Normalize generated task types",
                "task_type": "initiative_plan",
                "status": "backlog",
                "metadata": {
                    "scripted_generated_tasks": [
                        {"title": "Bug fix", "task_type": "bugfix"},
                        {"title": "Bug fix with space", "task_type": "bug fix"},
                        {"title": "Research spike", "task_type": "research"},
                        {"title": "Housekeeping", "task_type": "chore"},
                    ],
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Plan body")

        resp = client.post(f"/api/tasks/{task['id']}/generate-tasks", json={"source": "latest"})
        assert resp.status_code == 200
        payload = resp.json()
        child_types = [child["task_type"] for child in payload["children"]]
        assert child_types == ["bug", "bug", "feature", "chore"]


def test_generate_tasks_endpoint_applies_policy_and_persists_defaults(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Generate with policy",
                "task_type": "initiative_plan",
                "hitl_mode": "supervised",
                "status": "backlog",
                "metadata": {
                    "scripted_generated_tasks": [
                        {"title": "Child A", "task_type": "feature", "status": "backlog", "hitl_mode": "autopilot"},
                        {"title": "Child B", "task_type": "feature", "status": "backlog", "hitl_mode": "supervised"},
                    ],
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Plan body")

        resp = client.post(
            f"/api/tasks/{task['id']}/generate-tasks",
            json={
                "source": "latest",
                "policy": {
                    "child_status": "queued",
                    "child_hitl_mode": "review_only",
                    "infer_deps": False,
                },
                "save_as_default": True,
            },
        )
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["effective_policy"]["child_status"] == "queued"
        assert payload["effective_policy"]["child_hitl_mode"] == "review_only"
        assert payload["effective_policy"]["infer_deps"] is False
        for child in payload["children"]:
            assert child["status"] == "queued"
            assert child["hitl_mode"] == "review_only"

        container = app.state.containers[str(tmp_path.resolve())]
        parent = container.tasks.get(task["id"])
        assert parent is not None
        defaults = dict((parent.metadata or {}).get("task_generation_defaults") or {})
        assert defaults == {
            "child_status": "queued",
            "child_hitl_mode": "review_only",
            "infer_deps": False,
        }


def test_generate_tasks_endpoint_uses_saved_defaults_when_policy_omitted(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Generate from saved defaults",
                "task_type": "initiative_plan",
                "hitl_mode": "supervised",
                "status": "backlog",
                "metadata": {
                    "scripted_generated_tasks": [
                        {"id": "one", "title": "One", "task_type": "feature"},
                        {"id": "two", "title": "Two", "task_type": "feature", "depends_on": ["one"]},
                    ],
                },
            },
        ).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        persisted = container.tasks.get(task["id"])
        assert persisted is not None
        persisted.metadata["task_generation_defaults"] = {
            "child_status": "queued",
            "child_hitl_mode": "inherit_parent",
            "infer_deps": False,
        }
        container.tasks.upsert(persisted)
        _create_plan_revision(client, task["id"], "Plan body")

        resp = client.post(f"/api/tasks/{task['id']}/generate-tasks", json={"source": "latest"})
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["effective_policy"]["child_status"] == "queued"
        assert payload["effective_policy"]["child_hitl_mode"] == "supervised"
        assert payload["effective_policy"]["child_hitl_mode_selection"] == "inherit_parent"
        assert payload["effective_policy"]["infer_deps"] is False

        first = payload["children"][0]
        second = payload["children"][1]
        assert first["status"] == "queued"
        assert second["status"] == "queued"
        assert first["hitl_mode"] == "supervised"
        assert second["hitl_mode"] == "supervised"
        assert second.get("blocked_by") == []


def test_generate_tasks_endpoint_rejects_incompatible_pipeline(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Feature task",
                "task_type": "feature",
                "status": "backlog",
                "metadata": {
                    "scripted_generated_tasks": [{"title": "Child", "task_type": "feature"}],
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Plan body")

        resp = client.post(f"/api/tasks/{task['id']}/generate-tasks", json={"source": "latest"})
        assert resp.status_code == 400
        assert "does not support task generation" in resp.json()["detail"]


def test_generate_tasks_endpoint_returns_400_when_worker_outputs_no_tasks(tmp_path: Path) -> None:
    """Explicit generate should fail loudly when worker returns no task list."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Generate no output",
                "task_type": "initiative_plan",
                "status": "backlog",
                "metadata": {
                    "scripted_steps": {
                        "generate_tasks": {"status": "ok", "summary": "No decomposition possible."}
                    },
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Build auth")

        resp = client.post(f"/api/tasks/{task['id']}/generate-tasks", json={"source": "latest"})
        assert resp.status_code == 400
        assert "no generated tasks" in resp.json()["detail"].lower()


def test_generate_tasks_with_override(tmp_path: Path) -> None:
    """POST with plan_override doesn't require stored plan."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Override plan test",
                "task_type": "initiative_plan",
                "metadata": {
                    "scripted_generated_tasks": [
                        {"title": "From override", "task_type": "feature"},
                    ],
                },
            },
        ).json()["task"]

        # No stored plan, no override → 400
        resp_fail = client.post(
            f"/api/tasks/{task['id']}/generate-tasks",
            json={},
        )
        assert resp_fail.status_code == 400
        assert "No plan revision exists" in resp_fail.json()["detail"]

        # With override → success
        resp = client.post(
            f"/api/tasks/{task['id']}/generate-tasks",
            json={"source": "override", "plan_override": "Custom plan text"},
        )
        assert resp.status_code == 200
        assert len(resp.json()["created_task_ids"]) == 1
        assert resp.json()["children"][0]["title"] == "From override"


def test_approve_gate_before_generate_tasks_accepts_generation_policy(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Gate task",
                "task_type": "initiative_plan",
                "hitl_mode": "supervised",
                "status": "in_progress",
            },
        ).json()["task"]

        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None
        task.pending_gate = "before_generate_tasks"
        container.tasks.upsert(task)

        resp = client.post(
            f"/api/tasks/{task.id}/approve-gate",
            json={
                "action": "approve",
                "generation_policy": {
                    "child_status": "queued",
                    "child_hitl_mode": "review_only",
                    "infer_deps": False,
                },
                "save_generation_policy_as_default": True,
            },
        )
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["effective_generation_policy"]["child_status"] == "queued"
        assert payload["effective_generation_policy"]["child_hitl_mode"] == "review_only"
        assert payload["effective_generation_policy"]["infer_deps"] is False

        updated = container.tasks.get(task.id)
        assert updated is not None
        override = dict((updated.metadata or {}).get("task_generation_override") or {})
        assert override == {
            "child_status": "queued",
            "child_hitl_mode": "review_only",
            "infer_deps": False,
        }
        defaults = dict((updated.metadata or {}).get("task_generation_defaults") or {})
        assert defaults == {
            "child_status": "queued",
            "child_hitl_mode": "review_only",
            "infer_deps": False,
        }


def test_approve_gate_rejects_generation_policy_for_non_generate_gate(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post(
            "/api/tasks",
            json={
                "title": "Non-generate gate task",
                "task_type": "feature",
                "status": "in_progress",
            },
        ).json()["task"]
        container = app.state.containers[str(tmp_path.resolve())]
        task = container.tasks.get(created["id"])
        assert task is not None
        task.pending_gate = "before_implement"
        container.tasks.upsert(task)

        resp = client.post(
            f"/api/tasks/{task.id}/approve-gate",
            json={
                "action": "approve",
                "generation_policy": {"child_status": "queued"},
            },
        )
        assert resp.status_code == 400
        assert "only valid for the before_generate_tasks gate" in resp.json()["detail"]


def test_plan_refine_job_lifecycle_and_commit(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Iterative plan",
                "metadata": {
                    "scripted_steps": {
                        "plan_refine": {"status": "ok", "summary": "Refined plan with rollout"}
                    },
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Initial plan")

        initial_doc = client.get(f"/api/tasks/{task['id']}/plan").json()
        assert len(initial_doc["revisions"]) == 1
        base_revision_id = initial_doc["latest_revision_id"]

        queued = client.post(
            f"/api/tasks/{task['id']}/plan/refine",
            json={"base_revision_id": base_revision_id, "feedback": "Add rollout and risk checks"},
        )
        assert queued.status_code == 200
        job_id = queued.json()["job"]["id"]

        status = ""
        for _ in range(50):
            job = client.get(f"/api/tasks/{task['id']}/plan/jobs/{job_id}").json()["job"]
            status = job["status"]
            if status in {"completed", "failed", "cancelled"}:
                break
            time.sleep(0.05)
        assert status == "completed"
        result_revision_id = job["result_revision_id"]
        assert result_revision_id

        doc = client.get(f"/api/tasks/{task['id']}/plan").json()
        assert doc["latest_revision_id"] == result_revision_id
        assert len(doc["revisions"]) == 2
        refined = next(item for item in doc["revisions"] if item["id"] == result_revision_id)
        assert refined["source"] == "worker_refine"
        assert refined["parent_revision_id"] == base_revision_id

        commit = client.post(
            f"/api/tasks/{task['id']}/plan/commit",
            json={"revision_id": result_revision_id},
        )
        assert commit.status_code == 200
        assert commit.json()["committed_revision_id"] == result_revision_id

        doc_after_commit = client.get(f"/api/tasks/{task['id']}/plan").json()
        assert doc_after_commit["committed_revision_id"] == result_revision_id


def test_initiative_plan_refine_uses_initiative_refine_step(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Initiative refine",
                "task_type": "initiative_plan",
                "metadata": {
                    "scripted_steps": {
                        "initiative_plan_refine": {"status": "ok", "summary": "Refined initiative plan with updated sequencing"}
                    },
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Initial initiative plan")

        initial_doc = client.get(f"/api/tasks/{task['id']}/plan").json()
        base_revision_id = initial_doc["latest_revision_id"]

        queued = client.post(
            f"/api/tasks/{task['id']}/plan/refine",
            json={"base_revision_id": base_revision_id, "feedback": "Adjust sequencing by risk"},
        )
        assert queued.status_code == 200
        job_id = queued.json()["job"]["id"]

        status = ""
        for _ in range(50):
            job = client.get(f"/api/tasks/{task['id']}/plan/jobs/{job_id}").json()["job"]
            status = job["status"]
            if status in {"completed", "failed", "cancelled"}:
                break
            time.sleep(0.05)
        assert status == "completed"

        result_revision_id = job["result_revision_id"]
        doc = client.get(f"/api/tasks/{task['id']}/plan").json()
        refined = next(item for item in doc["revisions"] if item["id"] == result_revision_id)
        assert refined["step"] == "initiative_plan_refine"


def test_plan_refine_failure_path(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Broken refine",
                "metadata": {
                    "scripted_steps": {"plan_refine": {"status": "error", "summary": "worker failed"}},
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Base")

        queued = client.post(
            f"/api/tasks/{task['id']}/plan/refine",
            json={"feedback": "Refine this"},
        )
        assert queued.status_code == 200
        job_id = queued.json()["job"]["id"]

        status = ""
        for _ in range(50):
            job = client.get(f"/api/tasks/{task['id']}/plan/jobs/{job_id}").json()["job"]
            status = job["status"]
            if status in {"completed", "failed", "cancelled"}:
                break
            time.sleep(0.05)
        assert status == "failed"
        assert "worker failed" in str(job.get("error") or "")


def test_plan_refine_emit_error_does_not_mark_job_failed(tmp_path: Path, monkeypatch) -> None:
    from overdrive.runtime.events.bus import EventBus

    original_emit = EventBus.emit

    def flaky_emit(self, *, channel: str, event_type: str, entity_id: str, payload: dict | None = None):
        if event_type == "plan.refine.completed":
            raise RuntimeError("synthetic bus failure")
        return original_emit(self, channel=channel, event_type=event_type, entity_id=entity_id, payload=payload)

    monkeypatch.setattr(EventBus, "emit", flaky_emit)

    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Refine resilient finalize",
                "metadata": {
                    "scripted_steps": {"plan_refine": {"status": "ok", "summary": "Refined output"}},
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Base plan")

        queued = client.post(
            f"/api/tasks/{task['id']}/plan/refine",
            json={"feedback": "Please refine"},
        )
        assert queued.status_code == 200
        job_id = queued.json()["job"]["id"]

        status = ""
        for _ in range(50):
            job = client.get(f"/api/tasks/{task['id']}/plan/jobs/{job_id}").json()["job"]
            status = job["status"]
            if status in {"completed", "failed", "cancelled"}:
                break
            time.sleep(0.05)

        assert status == "completed"
        assert job["result_revision_id"]
        doc = client.get(f"/api/tasks/{task['id']}/plan").json()
        assert any(item["id"] == job["result_revision_id"] for item in doc["revisions"])


def test_generate_tasks_with_explicit_plan_sources(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post(
            "/api/tasks",
            json={
                "title": "Source selection",
                "task_type": "initiative_plan",
                "metadata": {
                    "scripted_generated_tasks": [{"title": "From selected source", "task_type": "feature"}],
                },
            },
        ).json()["task"]
        _create_plan_revision(client, task["id"], "Base plan")

        plan_doc = client.get(f"/api/tasks/{task['id']}/plan").json()
        latest_revision_id = plan_doc["latest_revision_id"]

        manual = client.post(
            f"/api/tasks/{task['id']}/plan/revisions",
            json={"content": "Manual plan text", "parent_revision_id": latest_revision_id, "feedback_note": "manual tweak"},
        )
        assert manual.status_code == 200
        manual_revision_id = manual.json()["revision"]["id"]

        bad_revision = client.post(
            f"/api/tasks/{task['id']}/generate-tasks",
            json={"source": "revision"},
        )
        assert bad_revision.status_code == 400

        good_revision = client.post(
            f"/api/tasks/{task['id']}/generate-tasks",
            json={"source": "revision", "revision_id": manual_revision_id},
        )
        assert good_revision.status_code == 200
        assert good_revision.json()["source"] == "revision"
        assert good_revision.json()["source_revision_id"] == manual_revision_id

        commit = client.post(
            f"/api/tasks/{task['id']}/plan/commit",
            json={"revision_id": manual_revision_id},
        )
        assert commit.status_code == 200

        committed_source = client.post(
            f"/api/tasks/{task['id']}/generate-tasks",
            json={"source": "committed"},
        )
        assert committed_source.status_code == 200
        assert committed_source.json()["source_revision_id"] == manual_revision_id


# ---------------------------------------------------------------------------
# Workdoc API
# ---------------------------------------------------------------------------


def test_get_workdoc_returns_content(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Workdoc task"}).json()["task"]
        task_id = task["id"]

        # Manually init the workdoc via the orchestrator service
        from overdrive.runtime.orchestrator.service import OrchestratorService
        from overdrive.runtime.events.bus import EventBus
        from overdrive.runtime.storage.container import Container

        container = Container(tmp_path)
        bus = EventBus(container.events, container.project_id)
        svc = OrchestratorService(container, bus, worker_adapter=DefaultWorkerAdapter())
        t = container.tasks.get(task_id)
        assert t is not None
        svc._init_workdoc(t, tmp_path)

        resp = client.get(f"/api/tasks/{task_id}/workdoc")
        assert resp.status_code == 200
        data = resp.json()
        assert data["task_id"] == task_id
        assert data["exists"] is True
        assert "Workdoc task" in data["content"]


def test_get_workdoc_returns_409_when_missing(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "No workdoc", "status": "backlog"}).json()["task"]
        task_id = task["id"]

        resp = client.get(f"/api/tasks/{task_id}/workdoc")
        assert resp.status_code == 409
        assert "Missing required workdoc" in resp.json()["detail"]


def test_get_workdoc_returns_409_when_invalid_encoding(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        task = client.post("/api/tasks", json={"title": "Bad encoding workdoc", "status": "backlog"}).json()["task"]
        task_id = task["id"]

        from overdrive.runtime.orchestrator.service import OrchestratorService
        from overdrive.runtime.events.bus import EventBus
        from overdrive.runtime.storage.container import Container

        container = Container(tmp_path)
        bus = EventBus(container.events, container.project_id)
        svc = OrchestratorService(container, bus, worker_adapter=DefaultWorkerAdapter())
        t = container.tasks.get(task_id)
        assert t is not None
        canonical = svc._init_workdoc(t, tmp_path)
        canonical.write_bytes(b"\xff\xfe\xfa")

        resp = client.get(f"/api/tasks/{task_id}/workdoc")
        assert resp.status_code == 409
        assert "Invalid workdoc encoding" in resp.json()["detail"]


def test_get_workdoc_404_for_unknown_task(tmp_path: Path) -> None:
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        resp = client.get("/api/tasks/nonexistent-id/workdoc")
        assert resp.status_code == 404


def test_task_worker_provider_roundtrip() -> None:
    """Task to_dict/from_dict round-trips worker_provider."""
    t = Task(title="test", worker_provider="claude")
    data = t.to_dict()
    assert data["worker_provider"] == "claude"
    restored = Task.from_dict(data)
    assert restored.worker_provider == "claude"

    t2 = Task(title="no provider")
    assert t2.worker_provider is None
    data2 = t2.to_dict()
    assert data2["worker_provider"] is None
    restored2 = Task.from_dict(data2)
    assert restored2.worker_provider is None


def test_create_task_with_worker_provider(tmp_path: Path) -> None:
    """POST /tasks with worker_provider persists it."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Provider override", "worker_provider": "claude"})
        assert created.status_code == 200
        task = created.json()["task"]
        assert task["worker_provider"] == "claude"

        loaded = client.get(f"/api/tasks/{task['id']}")
        assert loaded.status_code == 200
        assert loaded.json()["task"]["worker_provider"] == "claude"


def test_retry_task_with_worker_provider(tmp_path: Path) -> None:
    """POST /tasks/{id}/retry with worker_provider updates task field."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Retry provider", "status": "blocked"})
        assert created.status_code == 200
        task_id = created.json()["task"]["id"]
        assert created.json()["task"].get("worker_provider") is None

        retried = client.post(f"/api/tasks/{task_id}/retry", json={"worker_provider": "claude"})
        assert retried.status_code == 200
        assert retried.json()["task"]["worker_provider"] == "claude"
        assert retried.json()["task"]["status"] == "queued"


def test_patch_task_worker_provider(tmp_path: Path) -> None:
    """PATCH /tasks/{id} with worker_provider updates task field."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        created = client.post("/api/tasks", json={"title": "Patch provider"})
        assert created.status_code == 200
        task_id = created.json()["task"]["id"]
        assert created.json()["task"].get("worker_provider") is None

        patched = client.patch(f"/api/tasks/{task_id}", json={"worker_provider": "ollama"})
        assert patched.status_code == 200
        assert patched.json()["task"]["worker_provider"] == "ollama"

        loaded = client.get(f"/api/tasks/{task_id}")
        assert loaded.status_code == 200
        assert loaded.json()["task"]["worker_provider"] == "ollama"


# ---------------------------------------------------------------------------
# provider_spec_to_dict / normalization consolidation tests
# ---------------------------------------------------------------------------

from overdrive.workers.config import (
    WorkerProviderSpec,
    provider_spec_to_dict,
    get_workers_runtime_config,
)


def test_provider_spec_to_dict_codex() -> None:
    """Codex spec serializes with command, model, reasoning_effort; omits unused fields."""
    spec = WorkerProviderSpec(
        name="codex",
        type="codex",
        command="codex exec",
        model="o4-mini",
        reasoning_effort="high",
        execution_mode="sandboxed",
        capabilities=("docker",),
    )
    d = provider_spec_to_dict(spec)
    assert d == {
        "type": "codex",
        "execution_mode": "sandboxed",
        "command": "codex exec",
        "model": "o4-mini",
        "reasoning_effort": "high",
        "capabilities": ["docker"],
    }
    # Ensure ollama-specific fields are absent
    assert "endpoint" not in d
    assert "temperature" not in d
    assert "num_ctx" not in d
    # name is never in the output
    assert "name" not in d


def test_provider_spec_to_dict_claude() -> None:
    """Claude spec includes execution_mode=host_access and capabilities as list."""
    spec = WorkerProviderSpec(
        name="claude",
        type="claude",
        command="claude -p",
        model="sonnet",
        execution_mode="host_access",
        capabilities=("docker", "git"),
    )
    d = provider_spec_to_dict(spec)
    assert d["type"] == "claude"
    assert d["execution_mode"] == "host_access"
    assert d["command"] == "claude -p"
    assert d["model"] == "sonnet"
    assert d["capabilities"] == ["docker", "git"]
    assert isinstance(d["capabilities"], list)


def test_provider_spec_to_dict_ollama() -> None:
    """Ollama spec includes endpoint, model, temperature, num_ctx."""
    spec = WorkerProviderSpec(
        name="local-llm",
        type="ollama",
        endpoint="http://localhost:11434",
        model="llama3",
        temperature=0.7,
        num_ctx=4096,
    )
    d = provider_spec_to_dict(spec)
    assert d == {
        "type": "ollama",
        "execution_mode": "sandboxed",
        "endpoint": "http://localhost:11434",
        "model": "llama3",
        "temperature": 0.7,
        "num_ctx": 4096,
    }
    assert "command" not in d


def test_provider_spec_to_dict_minimal() -> None:
    """Spec with only required fields omits all optional fields from output."""
    spec = WorkerProviderSpec(name="bare", type="codex")
    d = provider_spec_to_dict(spec)
    assert d == {"type": "codex", "execution_mode": "sandboxed"}
    assert len(d) == 2


def test_get_workers_runtime_config_local_alias() -> None:
    """Config with type 'local' normalizes to 'ollama' through the full pipeline."""
    config = {
        "workers": {
            "providers": {
                "my-local": {
                    "type": "local",
                    "endpoint": "http://localhost:11434",
                    "model": "llama3",
                },
            },
        },
    }
    runtime = get_workers_runtime_config(config=config, codex_command_fallback="codex exec")
    assert "my-local" in runtime.providers
    spec = runtime.providers["my-local"]
    assert spec.type == "ollama"
    d = provider_spec_to_dict(spec)
    assert d["type"] == "ollama"


def test_get_workers_runtime_config_codex_fallback() -> None:
    """Empty providers config still produces a codex entry with defaults."""
    config: dict[str, object] = {"workers": {}}
    runtime = get_workers_runtime_config(config=config, codex_command_fallback="codex exec")
    assert "codex" in runtime.providers
    d = provider_spec_to_dict(runtime.providers["codex"])
    assert d["type"] == "codex"
    assert d["command"] == "codex exec"
    assert d["execution_mode"] == "sandboxed"


def test_settings_payload_providers_match(tmp_path: Path) -> None:
    """GET /api/settings returns normalized providers via provider_spec_to_dict."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    with TestClient(app) as client:
        resp = client.get("/api/settings")
        assert resp.status_code == 200
        settings = resp.json()
        providers = settings["workers"]["providers"]
        # codex is always present
        assert "codex" in providers
        codex = providers["codex"]
        assert codex["type"] == "codex"
        assert "command" in codex
        assert "execution_mode" in codex
        # default worker must be in providers
        default_worker = settings["workers"]["default"]
        assert default_worker in providers


def test_provider_spec_to_dict_capabilities_dedup() -> None:
    """Capabilities are deduplicated and lowered by get_workers_runtime_config."""
    config = {
        "workers": {
            "providers": {
                "claude": {
                    "type": "claude",
                    "capabilities": ["docker", "DOCKER", "docker", "git"],
                },
            },
        },
    }
    runtime = get_workers_runtime_config(config=config, codex_command_fallback="codex exec")
    d = provider_spec_to_dict(runtime.providers["claude"])
    assert d["capabilities"] == ["docker", "git"]


def test_provider_spec_to_dict_invalid_reasoning_effort_omitted() -> None:
    """Invalid reasoning_effort is dropped by get_workers_runtime_config."""
    config = {
        "workers": {
            "providers": {
                "codex": {
                    "type": "codex",
                    "reasoning_effort": "invalid",
                },
            },
        },
    }
    runtime = get_workers_runtime_config(config=config, codex_command_fallback="codex exec")
    d = provider_spec_to_dict(runtime.providers["codex"])
    assert "reasoning_effort" not in d


def test_settings_payload_default_worker_fallback(tmp_path: Path) -> None:
    """Default worker falls back to codex when configured default is unknown."""
    app = create_app(project_dir=tmp_path, worker_adapter=DefaultWorkerAdapter())
    # Write a config with a non-existent default worker
    config_dir = tmp_path / ".overdrive"
    config_dir.mkdir(exist_ok=True)
    config_file = config_dir / "config.yaml"
    config_file.write_text("workers:\n  default: nonexistent\n")
    with TestClient(app) as client:
        resp = client.get("/api/settings")
        assert resp.status_code == 200
        assert resp.json()["workers"]["default"] == "codex"
