"""Knowledge index and refresh behavior tests."""

from __future__ import annotations

import asyncio
import base64
import json
import os
import subprocess
import sys
import traceback
from contextlib import asynccontextmanager
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from pathlib import Path
from threading import Event, get_ident
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from unittest.mock import MagicMock

import httpx
import pytest
from fastapi.testclient import TestClient
from openai import AuthenticationError
from pydantic import ValidationError
from structlog.testing import capture_logs
from watchfiles import Change

import mindroom.knowledge.file_listing as knowledge_file_listing_module
import mindroom.knowledge.git_source as knowledge_git_source_module
import mindroom.knowledge.manager as knowledge_manager_module
import mindroom.knowledge.refresh_locks as knowledge_refresh_locks
import mindroom.knowledge.refresh_runner as knowledge_refresh_runner
import mindroom.knowledge.refresh_scheduler as knowledge_refresh_scheduler
import mindroom.knowledge.registry as knowledge_registry
import mindroom.knowledge.utils as knowledge_utils
from mindroom import embedder_health, file_locks
from mindroom.api import config_lifecycle, main
from mindroom.api import knowledge as knowledge_api
from mindroom.background_tasks import wait_for_background_tasks
from mindroom.config.agent import AgentConfig, AgentPrivateConfig, AgentPrivateKnowledgeConfig
from mindroom.config.knowledge import KnowledgeBaseConfig, KnowledgeGitConfig
from mindroom.config.main import Config
from mindroom.credentials import get_runtime_shared_credentials_manager
from mindroom.credentials_sync import get_embedder_api_key
from mindroom.file_memory_knowledge import resolve_file_memory_knowledge
from mindroom.knowledge import KnowledgeRefreshScheduler, resolve_agent_knowledge_access
from mindroom.knowledge.availability import KnowledgeAvailability
from mindroom.knowledge.candidate_checkpoint import load_candidate_checkpoint
from mindroom.knowledge.collections import build_vector_db, candidate_collection_name
from mindroom.knowledge.file_listing import (
    git_checkout_present,
    knowledge_files_from_relative_paths,
    list_git_tracked_knowledge_files,
    list_knowledge_files,
)
from mindroom.knowledge.git_source import GitKnowledgeSource, GitSyncResult
from mindroom.knowledge.indexing_config import IndexingSettings
from mindroom.knowledge.manager import KnowledgeManager, _knowledge_source_signature
from mindroom.knowledge.redaction import (
    credential_free_repo_url,
    credential_free_url_identity,
    redact_credentials_in_text,
    redact_url_credentials,
)
from mindroom.knowledge.refresh_outcome import RefreshOutcome
from mindroom.knowledge.refresh_runner import knowledge_binding_mutation_lock, refresh_knowledge_binding
from mindroom.knowledge.registry import (
    PublishedIndexState,
    get_published_index,
    load_published_index_state,
    published_index_metadata_path,
    published_index_refresh_state,
    resolve_published_index_key,
    save_published_index_state,
)
from mindroom.knowledge.utils import KnowledgeAvailabilityDetail
from mindroom.knowledge.watch import KnowledgeSourceWatcher
from mindroom.memory_scope_ids import agent_scope_user_id
from mindroom.runtime_resolution import resolve_agent_runtime
from mindroom.tool_system.worker_routing import ToolExecutionIdentity, agent_workspace_root_path
from tests.conftest import bind_runtime_paths, runtime_paths_for, test_runtime_paths
from tests.knowledge_test_support import (
    _Client,
    _Collection,
    _config,
    _Knowledge,
    _vector_row_ids,
    _VectorDb,
    patch_vector_store,  # noqa: F401  # requested via pytestmark below
)

pytestmark = pytest.mark.usefixtures("patch_vector_store")

if TYPE_CHECKING:
    from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
    from contextlib import AbstractAsyncContextManager
    from types import ModuleType

    from agno.knowledge.reader.base import Reader

    from mindroom.constants import RuntimePaths


def _insert_with_real_reader(
    self: _Knowledge,
    *,
    path: str,
    metadata: dict[str, object],
    upsert: bool,
    reader: object | None = None,
) -> None:
    """Exercise the selected Agno reader while keeping vectors in the test store."""
    _ = upsert
    selected_reader = cast("Reader", reader)
    documents = selected_reader.read(Path(path), name=Path(path).name)
    with _VectorDb.lock:
        _VectorDb.collections.setdefault(self.vector_db.collection_name, []).extend(
            {
                "id": f"row-{next(_vector_row_ids)}",
                "content": document.content,
                "embedding": [1.0],
                "metadata": {**metadata, **document.meta_data},
            }
            for document in documents
        )


class _AutoCreatingKnowledge(_Knowledge):
    def __init__(self, vector_db: _VectorDb) -> None:
        super().__init__(vector_db)
        if not vector_db.exists():
            vector_db.create()


async def _wait_for_refresh_lock_borrowers(
    key: knowledge_registry.KnowledgeSourceRoot,
    expected: int,
) -> None:
    for _ in range(50):
        entry = knowledge_refresh_locks._refresh_locks.get(key)
        if entry is not None and entry.borrowers == expected:
            return
        await asyncio.sleep(0)
    pytest.fail(f"refresh lock for {key} did not reach {expected} borrowers")


def _create_idle_refresh_lock(key: knowledge_registry.KnowledgeSourceRoot) -> None:
    entry = knowledge_refresh_locks._borrow_refresh_lock_for_key(key)
    knowledge_refresh_locks._release_refresh_lock_for_key(key, entry)


def _base_storage_path(config: Config, runtime_paths: RuntimePaths, base_id: str = "docs") -> Path:
    """Return the private storage directory holding one base's candidate state."""
    return knowledge_registry.published_index_storage_path(
        resolve_published_index_key(base_id, config=config, runtime_paths=runtime_paths),
    )


def _test_indexing_settings(base_id: str = "docs") -> IndexingSettings:
    return IndexingSettings(
        base_id=base_id,
        storage_root="storage",
        knowledge_path=f"knowledge/{base_id}",
        mode="semantic",
        embedder_provider="openai",
        embedder_model="text-embedding-3-small",
        embedder_host="",
        embedder_dimensions="",
        chunk_size="5000",
        chunk_overlap="0",
        repo_identity="",
        git_branch="",
        git_lfs="",
        git_skip_hidden="",
        git_include_patterns="",
        git_exclude_patterns="",
        include_patterns="()",
        exclude_patterns="()",
        include_extensions="",
        exclude_extensions="()",
        extra_extensions="()",
    )


def _publish_api_config(api_app: object, config: Config) -> None:
    context = main._app_context(api_app)
    context.config_data = config.authored_model_dump()
    context.runtime_config = config
    context.config_load_result = main.ConfigLoadResult(success=True)


def _refresh_state_for_key(key: knowledge_registry.PublishedIndexKey) -> str:
    metadata_path = published_index_metadata_path(key)
    return knowledge_registry.published_index_refresh_state(
        load_published_index_state(metadata_path),
        metadata_exists=metadata_path.exists(),
    )


def test_load_published_index_state_preserves_file_mode_from_settings(tmp_path: Path) -> None:
    """Published file-mode metadata derives mode from indexing settings."""
    metadata_path = tmp_path / "indexing_settings.json"
    settings = replace(_test_indexing_settings(), mode="files")
    save_published_index_state(
        metadata_path,
        PublishedIndexState(settings=settings, status="complete", indexed_count=0, source_signature="source-signature"),
    )

    state = load_published_index_state(metadata_path)

    assert state is not None
    assert state.settings.mode == "files"
    assert state.collection is None


def _identity(requester_id: str, *, agent_name: str = "helper") -> ToolExecutionIdentity:
    return ToolExecutionIdentity(
        channel="matrix",
        agent_name=agent_name,
        requester_id=requester_id,
        room_id="!room:localhost",
        thread_id=None,
        resolved_thread_id=None,
        session_id="session",
    )


def _file_memory_config(tmp_path: Path, *agent_names: str) -> Config:
    runtime_paths = test_runtime_paths(tmp_path)
    return bind_runtime_paths(
        Config(
            agents={name: AgentConfig(display_name=name.title()) for name in agent_names},
            models={},
            memory={"backend": "file", "search": {"mode": "semantic"}},
        ),
        runtime_paths,
    )


def _scheduled_file_memory_overlay(
    agent_name: str,
    config: Config,
    runtime_paths: RuntimePaths,
) -> tuple[str, Config]:
    scheduler = MagicMock()
    scheduler.is_refreshing.return_value = False

    resolution = resolve_agent_knowledge_access(
        agent_name,
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )

    assert resolution.knowledge is None
    scheduler.schedule_refresh.assert_called_once()
    call = scheduler.schedule_refresh.call_args
    assert call is not None
    base_id = call.args[0]
    effective_config = call.kwargs["config"]
    assert isinstance(base_id, str)
    assert isinstance(effective_config, Config)
    return base_id, effective_config


@pytest.mark.asyncio
async def test_file_memory_overlay_query_returns_memory_markdown_hit(tmp_path: Path) -> None:
    """A published file-memory overlay should be queryable through agent knowledge."""
    config = _file_memory_config(tmp_path, "helper")
    runtime_paths = runtime_paths_for(config)
    root = agent_workspace_root_path(runtime_paths.storage_root, "helper")
    memory_file = root / "memory" / "notes.md"
    memory_file.parent.mkdir(parents=True)
    query_marker = "issue256-query-marker"
    memory_file.write_text(f"{query_marker}\n", encoding="utf-8")

    base_id, effective_config = _scheduled_file_memory_overlay("helper", config, runtime_paths)
    await refresh_knowledge_binding(base_id, config=effective_config, runtime_paths=runtime_paths)

    knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
    assert knowledge is not None
    documents = knowledge.search(query_marker, max_results=5)

    assert any(query_marker in document.content for document in documents)
    assert any(document.meta_data.get("source_path") == "memory/notes.md" for document in documents)


@pytest.mark.asyncio
async def test_cold_file_memory_overlay_waits_for_managed_content_before_publish(tmp_path: Path) -> None:
    """A cold runtime memory base must remain initializing until its corpus exists."""
    config = _file_memory_config(tmp_path, "helper")
    runtime_paths = runtime_paths_for(config)
    root = agent_workspace_root_path(runtime_paths.storage_root, "helper")
    resolution = resolve_file_memory_knowledge(
        scope_user_id=agent_scope_user_id("helper"),
        root=root,
        config=config,
        search_config=config.resolve_entity("helper").memory_search,
    )

    empty_result = await refresh_knowledge_binding(
        resolution.base_id,
        config=resolution.config,
        runtime_paths=runtime_paths,
    )
    cold_lookup = get_published_index(
        resolution.base_id,
        config=resolution.config,
        runtime_paths=runtime_paths,
    )

    assert empty_result.index_published is False
    assert empty_result.availability is KnowledgeAvailability.INITIALIZING
    assert cold_lookup.index is None

    memory_file = root / "memory" / "notes.md"
    memory_file.parent.mkdir(parents=True)
    memory_file.write_text("published-after-content-exists\n", encoding="utf-8")
    populated_result = await refresh_knowledge_binding(
        resolution.base_id,
        config=resolution.config,
        runtime_paths=runtime_paths,
    )
    populated_lookup = get_published_index(
        resolution.base_id,
        config=resolution.config,
        runtime_paths=runtime_paths,
    )

    assert populated_result.index_published is True
    assert populated_result.availability is KnowledgeAvailability.READY
    assert populated_lookup.index is not None
    assert [
        document.content.strip() for document in populated_lookup.index.knowledge.search("published", max_results=5)
    ] == ["published-after-content-exists"]


@pytest.mark.asyncio
async def test_file_memory_knowledge_is_cross_agent_isolated(tmp_path: Path) -> None:
    """Distinct agent workspaces must never expose each other's file-memory index."""
    config = _file_memory_config(tmp_path, "alpha", "beta")
    runtime_paths = runtime_paths_for(config)
    alpha_runtime = resolve_agent_runtime("alpha", config, runtime_paths, execution_identity=None)
    beta_runtime = resolve_agent_runtime("beta", config, runtime_paths, execution_identity=None)
    assert alpha_runtime.file_memory_root is not None
    assert beta_runtime.file_memory_root is not None
    assert alpha_runtime.file_memory_root != beta_runtime.file_memory_root
    assert not alpha_runtime.file_memory_root.is_symlink()
    assert not beta_runtime.file_memory_root.is_symlink()
    alpha_marker = "issue256-alpha-only-marker"
    beta_marker = "issue256-beta-only-marker"
    alpha_file = alpha_runtime.file_memory_root / "memory" / "notes.md"
    beta_file = beta_runtime.file_memory_root / "memory" / "notes.md"
    alpha_file.parent.mkdir(parents=True)
    beta_file.parent.mkdir(parents=True)
    alpha_file.write_text(f"{alpha_marker}\n", encoding="utf-8")
    beta_file.write_text(f"{beta_marker}\n", encoding="utf-8")

    alpha_base_id, alpha_config = _scheduled_file_memory_overlay("alpha", config, runtime_paths)
    beta_base_id, beta_config = _scheduled_file_memory_overlay("beta", config, runtime_paths)
    assert alpha_base_id != beta_base_id
    assert alpha_config.knowledge_bases[alpha_base_id].path != beta_config.knowledge_bases[beta_base_id].path
    await refresh_knowledge_binding(alpha_base_id, config=alpha_config, runtime_paths=runtime_paths)
    await refresh_knowledge_binding(beta_base_id, config=beta_config, runtime_paths=runtime_paths)

    alpha_knowledge = resolve_agent_knowledge_access("alpha", config, runtime_paths).knowledge
    beta_knowledge = resolve_agent_knowledge_access("beta", config, runtime_paths).knowledge
    assert alpha_knowledge is not None
    assert beta_knowledge is not None
    alpha_own = alpha_knowledge.search(alpha_marker, max_results=5)
    beta_own = beta_knowledge.search(beta_marker, max_results=5)
    alpha_cross = alpha_knowledge.search(beta_marker, max_results=5)
    beta_cross = beta_knowledge.search(alpha_marker, max_results=5)

    assert any(alpha_marker in document.content for document in alpha_own)
    assert any(beta_marker in document.content for document in beta_own)
    assert all(beta_marker not in document.content for document in alpha_cross)
    assert all(alpha_marker not in document.content for document in beta_cross)


@pytest.mark.parametrize(
    ("authored_source_count", "expected_result_count", "memory_source_expected"),
    [
        pytest.param(10, 11, True, id="all-sources-fit"),
        pytest.param(25, 20, False, id="source-count-is-capped"),
    ],
)
def test_default_merged_search_budget_is_bounded_while_representing_sources_that_fit(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    authored_source_count: int,
    expected_result_count: int,
    memory_source_expected: bool,
) -> None:
    """Merged defaults represent every source only until the bounded result budget is full."""
    authored_base_ids = [f"source_{index}" for index in range(authored_source_count)]
    config = _file_memory_config(tmp_path, "helper")
    config.agents["helper"].knowledge_bases = authored_base_ids
    config.knowledge_bases.update(
        {
            base_id: KnowledgeBaseConfig(path=str(tmp_path / base_id), description=f"Source {base_id}")
            for base_id in authored_base_ids
        },
    )
    runtime_paths = runtime_paths_for(config)

    def _published_index(base_id: str, **_kwargs: object) -> object:
        vector_db = _VectorDb(collection=f"collection_{base_id}")
        vector_db.create()
        _VectorDb.collections[vector_db.collection_name] = [
            {
                "content": f"result from {base_id}",
                "metadata": {"source_path": f"{base_id}.md"},
            },
        ]
        knowledge = SimpleNamespace(
            vector_db=vector_db,
            name=None,
            description=None,
            max_results=10,
        )
        return SimpleNamespace(
            key=SimpleNamespace(base_id=base_id),
            index=SimpleNamespace(
                knowledge=knowledge,
                state=SimpleNamespace(last_refresh_at=None, last_published_at=None),
            ),
            availability=KnowledgeAvailability.READY,
            state=None,
            schedule_refresh_on_access=False,
        )

    monkeypatch.setattr(knowledge_utils, "get_published_index", _published_index)

    knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
    assert knowledge is not None
    documents = knowledge.search("anything")

    assert len(documents) == expected_result_count
    assert (
        any(document.content.startswith("result from file_memory_agent_helper_") for document in documents)
        is memory_source_expected
    )


def _record_git_sync(
    source: GitKnowledgeSource,
    result: GitSyncResult,
    *relative_paths: str,
) -> GitSyncResult:
    """Record a faked sync's outcome the way a real one would, then return it."""
    source._last_synced_head = result.head
    source._tracked_relative_paths = set(relative_paths)
    return result


def test_cold_git_status_with_existing_non_checkout_dir_returns_empty_files(tmp_path: Path) -> None:
    """Cold Git status should not run git ls-files before the checkout exists."""
    knowledge_path = tmp_path / "knowledge"
    knowledge_path.mkdir()
    config = _config(
        tmp_path,
        bases={"docs": knowledge_path},
        agent_bases=["docs"],
        git_configs={"docs": KnowledgeGitConfig(repo_url="https://example.com/org/repo.git")},
    )
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths_for(config))

    assert manager.list_files() == []
    assert not (knowledge_path / ".git").exists()


@pytest.mark.asyncio
async def test_git_manager_construction_does_not_probe_checkout_on_event_loop(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Git checkout detection during construction must stay filesystem-only."""
    knowledge_path = tmp_path / "knowledge"
    (knowledge_path / ".git").mkdir(parents=True)
    config = _config(
        tmp_path,
        bases={"docs": knowledge_path},
        agent_bases=["docs"],
        git_configs={"docs": KnowledgeGitConfig(repo_url="https://example.com/org/repo.git")},
    )

    checkout_probe = MagicMock(return_value=True)
    monkeypatch.setattr(knowledge_git_source_module, "git_checkout_present", checkout_probe)

    await asyncio.sleep(0)
    KnowledgeManager("docs", config=config, runtime_paths=runtime_paths_for(config))
    checkout_probe.assert_not_called()


def test_missing_shared_knowledge_schedules_refresh_and_returns_none(tmp_path: Path) -> None:
    """A missing published index schedules only the referenced base."""
    config = _config(
        tmp_path,
        bases={"docs": tmp_path / "docs", "unused": tmp_path / "unused"},
        agent_bases=["docs"],
    )
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    knowledge = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths_for(config),
        refresh_scheduler=scheduler,
    ).knowledge

    assert knowledge is None
    scheduler.schedule_refresh.assert_called_once()
    assert scheduler.schedule_refresh.call_args.args == ("docs",)
    assert scheduler.schedule_refresh.call_args.kwargs["config"] is config


def test_file_mode_knowledge_skips_semantic_lookup_and_refresh(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """File-only knowledge should not look up vectors or schedule embedding refreshes."""
    config = _config(
        tmp_path,
        bases={"docs": tmp_path / "docs"},
        agent_bases=["docs"],
        modes={"docs": "files"},
    )
    get_published_index = MagicMock(side_effect=AssertionError("semantic index lookup should be skipped"))
    monkeypatch.setattr(knowledge_utils, "get_published_index", get_published_index)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths_for(config),
        refresh_scheduler=scheduler,
    )

    assert resolution.knowledge is None
    assert resolution.unavailable == {}
    get_published_index.assert_not_called()
    scheduler.is_refreshing.assert_not_called()
    scheduler.schedule_refresh.assert_not_called()


def test_initializing_knowledge_skips_duplicate_initial_load_when_scheduler_is_active(tmp_path: Path) -> None:
    """An active scheduler refresh is enough for initializing knowledge."""
    config = _config(
        tmp_path,
        bases={"docs": tmp_path / "docs", "unused": tmp_path / "unused"},
        agent_bases=["docs"],
    )
    runtime_paths = runtime_paths_for(config)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=True)
    scheduler.schedule_refresh = MagicMock()

    knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths, refresh_scheduler=scheduler).knowledge

    assert knowledge is None
    scheduler.is_refreshing.assert_called_once()
    scheduler.schedule_refresh.assert_not_called()


def test_real_refresh_scheduler_without_running_loop_does_not_mark_active(tmp_path: Path) -> None:
    """Synchronous callers should not leave a binding stuck refreshing when no event loop is running."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()

    assert (
        resolve_agent_knowledge_access("helper", config, runtime_paths, refresh_scheduler=scheduler).knowledge is None
    )
    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    refresh_target = knowledge_registry.resolve_refresh_target("docs", config=config, runtime_paths=runtime_paths)

    assert knowledge_refresh_locks.is_refresh_active(refresh_target) is False
    assert scheduler.is_refreshing("docs", config=config, runtime_paths=runtime_paths) is False


def test_refresh_scheduler_module_exports_one_concrete_scheduler_name() -> None:
    """The refresh scheduler module should expose one concrete scheduler concept."""
    assert knowledge_refresh_scheduler.KnowledgeRefreshScheduler.__name__ == "KnowledgeRefreshScheduler"
    assert not hasattr(knowledge_refresh_scheduler, "StandaloneKnowledgeRefreshScheduler")
    assert not hasattr(knowledge_refresh_scheduler, "OrchestratorKnowledgeRefreshScheduler")
    assert not hasattr(knowledge_refresh_scheduler, "PerBindingKnowledgeRefreshScheduler")


@pytest.mark.asyncio
async def test_file_mode_refresh_publishes_source_metadata_without_vector_collection(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Refreshing file-only knowledge should avoid Chroma collections and embedders."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("Use grep for this source.", encoding="utf-8")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        modes={"docs": "files"},
    )
    runtime_paths = runtime_paths_for(config)
    embedder_factory = MagicMock(return_value=object())
    monkeypatch.setattr(knowledge_manager_module, "create_configured_embedder", embedder_factory)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths, force_reindex=True)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))

    assert result.indexed_count == 0
    assert result.index_published is True
    assert result.availability is KnowledgeAvailability.READY
    assert state is not None
    assert state.status == "complete"
    assert state.collection is None
    assert state.indexed_count == 0
    assert _VectorDb.collections == {}
    embedder_factory.assert_not_called()


@pytest.mark.asyncio
async def test_file_mode_git_refresh_marks_same_source_semantic_alias_stale(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """File-only Git sync should stale semantic indexes that read the same checkout."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("Use grep for this source.", encoding="utf-8")
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")
    config = _config(
        tmp_path,
        bases={"semantic_docs": docs_path, "file_docs": docs_path},
        agent_bases=["semantic_docs", "file_docs"],
        git_configs={"semantic_docs": git_config, "file_docs": git_config},
        modes={"file_docs": "files"},
    )
    runtime_paths = runtime_paths_for(config)
    semantic_key = resolve_published_index_key("semantic_docs", config=config, runtime_paths=runtime_paths)
    file_key = resolve_published_index_key("file_docs", config=config, runtime_paths=runtime_paths)
    semantic_collection = KnowledgeManager(
        "semantic_docs",
        config=config,
        runtime_paths=runtime_paths,
    )._collections.default_collection
    _VectorDb.collections[semantic_collection] = [
        {"content": "Use grep for this source.", "metadata": {"source_path": "guide.md"}},
    ]
    knowledge_registry.save_published_index_state(
        published_index_metadata_path(semantic_key),
        knowledge_registry.PublishedIndexState(
            settings=semantic_key.indexing_settings,
            status="complete",
            collection=semantic_collection,
            indexed_count=1,
            source_signature="old-source-signature",
        ),
    )
    knowledge_registry.mark_published_index_refresh_succeeded(semantic_key)

    async def _sync_updated(self: GitKnowledgeSource) -> GitSyncResult:
        assert self.base_id == "file_docs"
        return _record_git_sync(self, GitSyncResult(head="rev-updated", updated=True), "guide.md")

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_updated)

    result = await refresh_knowledge_binding("file_docs", config=config, runtime_paths=runtime_paths)
    semantic_state = load_published_index_state(published_index_metadata_path(semantic_key))
    file_state = load_published_index_state(published_index_metadata_path(file_key))

    assert result.availability is KnowledgeAvailability.READY
    assert semantic_state is not None
    assert knowledge_registry.published_index_refresh_state(semantic_state) == "stale"
    assert file_state is not None
    assert file_state.status == "complete"
    assert file_state.settings.mode == "files"
    assert knowledge_registry.published_index_refresh_state(file_state) == "none"


@pytest.mark.asyncio
async def test_file_mode_cancelled_refresh_after_metadata_publish_stays_complete(tmp_path: Path) -> None:
    """Cancellation recovery should not require vector state for file-only metadata."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("Use grep for this source.", encoding="utf-8")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        modes={"docs": "files"},
    )
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths, force_reindex=True)
    await knowledge_refresh_runner._reconcile_cancelled_refresh(
        key,
        initial_state=None,
        config=config,
        runtime_paths=runtime_paths,
    )
    state = load_published_index_state(published_index_metadata_path(key))

    assert state is not None
    assert state.status == "complete"
    assert state.settings.mode == "files"
    assert state.collection is None
    assert published_index_refresh_state(state) == "none"
    assert state.reason is None


@pytest.mark.asyncio
async def test_file_mode_reindex_reports_an_empty_unpublished_outcome(tmp_path: Path) -> None:
    """A file-only base builds no vectors, so its refresh publishes nothing and reports no failure."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        modes={"docs": "files"},
    )
    runtime_paths = runtime_paths_for(config)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths)

    assert await manager.reindex_all() == RefreshOutcome(indexed_count=0, published=False, error=None)


def test_file_mode_source_signature_tracks_non_semantic_files(tmp_path: Path) -> None:
    """File-only metadata should cover every managed file agents can inspect."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("Use grep for this source.", encoding="utf-8")
    diagram = docs_path / "diagram.png"
    diagram.write_bytes(b"before")
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
        modes={"docs": "files"},
    )

    before = _knowledge_source_signature(
        config,
        "docs",
        docs_path,
        tracked_relative_paths={"guide.md", "diagram.png"},
    )
    diagram.write_bytes(b"after")

    assert (
        _knowledge_source_signature(
            config,
            "docs",
            docs_path,
            tracked_relative_paths={"guide.md", "diagram.png"},
        )
        != before
    )


def test_failed_notice_without_index_says_unavailable() -> None:
    """Cold failed knowledge must not be described as stale when no index is attached."""
    notice = knowledge_utils.format_knowledge_availability_notice(
        {
            "docs": KnowledgeAvailabilityDetail(
                availability=KnowledgeAvailability.REFRESH_FAILED,
                search_available=False,
            ),
        },
    )

    assert notice is not None
    assert "unavailable for semantic search this turn" in notice
    assert "may be stale" not in notice
    assert "Do not claim to have searched it." in notice


def test_failed_notice_appends_classified_last_error_cause() -> None:
    """A refresh-failed notice extracts only the classified cause from the summary."""
    notice = knowledge_utils.format_knowledge_availability_notice(
        {
            "docs": KnowledgeAvailabilityDetail(
                availability=KnowledgeAvailability.REFRESH_FAILED,
                search_available=False,
                last_error="Indexed 0 of 3 managed knowledge files (first error: "
                "embedder authentication failed (HTTP 401))",
            ),
        },
    )

    assert notice is not None
    assert notice.endswith("Last error: embedder authentication failed (HTTP 401)")
    assert "Indexed 0 of 3" not in notice


def test_failed_notice_never_renders_unclassified_last_error() -> None:
    """Operator-grade free text in last_error stays out of model-facing prompts."""
    notice = knowledge_utils.format_knowledge_availability_notice(
        {
            "docs": KnowledgeAvailabilityDetail(
                availability=KnowledgeAvailability.REFRESH_FAILED,
                search_available=False,
                last_error="git sync failed: fatal: could not read from https://token@git.example.com/repo.git",
            ),
        },
    )

    assert notice is not None
    assert "Last error" not in notice
    assert "git sync failed" not in notice
    assert "token" not in notice


def test_stale_failed_notice_appends_last_error_cause() -> None:
    """A last-good-index refresh failure still appends the persisted cause."""
    notice = knowledge_utils.format_knowledge_availability_notice(
        {
            "docs": KnowledgeAvailabilityDetail(
                availability=KnowledgeAvailability.REFRESH_FAILED,
                search_available=True,
                last_error="embedder endpoint unreachable",
            ),
        },
    )

    assert notice is not None
    assert "may be stale this turn" in notice
    assert notice.endswith("Last error: embedder endpoint unreachable")


def test_config_mismatch_notice_without_index_says_unavailable() -> None:
    """Cold config-mismatched knowledge must not imply stale semantic search occurred."""
    notice = knowledge_utils.format_knowledge_availability_notice(
        {
            "docs": KnowledgeAvailabilityDetail(
                availability=KnowledgeAvailability.CONFIG_MISMATCH,
                search_available=False,
            ),
        },
    )

    assert notice is not None
    assert "unavailable for semantic search this turn" in notice
    assert "may be stale" not in notice
    assert "Do not claim to have searched it." in notice


def test_stale_notice_without_index_says_unavailable() -> None:
    """Stale metadata without a loadable index must not imply semantic search occurred."""
    notice = knowledge_utils.format_knowledge_availability_notice(
        {
            "docs": KnowledgeAvailabilityDetail(
                availability=KnowledgeAvailability.STALE,
                search_available=False,
            ),
        },
    )

    assert notice is not None
    assert "unavailable for semantic search this turn" in notice
    assert "may be stale" not in notice
    assert "Do not claim to have searched it." in notice


@pytest.mark.asyncio
async def test_ready_index_access_does_not_refresh_unchanged_sources(tmp_path: Path) -> None:
    """A ready index is returned immediately without churn when sources are unchanged."""
    docs_path = tmp_path / "docs"
    unused_path = tmp_path / "unused"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("ready index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path, "unused": unused_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths, refresh_scheduler=scheduler).knowledge
    second_knowledge = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    ).knowledge

    assert knowledge is not None
    assert second_knowledge is not None
    assert [document.content for document in knowledge.search("index", max_results=5)] == ["ready index"]
    scheduler.schedule_refresh.assert_not_called()
    assert len(_VectorDb.collections) == 1


@pytest.mark.asyncio
async def test_shared_local_watch_index_refreshes_on_access_without_blocking_read(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Shared local bases with watch=true schedule refresh on access while serving last-good content."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("shared local old", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"], watch=True)
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("shared local new", encoding="utf-8")
    monkeypatch.setattr(
        "mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess",
        refresh_knowledge_binding,
    )
    scheduler = KnowledgeRefreshScheduler()

    try:
        knowledge = resolve_agent_knowledge_access(
            "helper",
            config,
            runtime_paths,
            refresh_scheduler=scheduler,
        ).knowledge
        assert knowledge is not None
        assert [document.content for document in knowledge.search("shared", max_results=5)] == ["shared local old"]

        for _attempt in range(500):
            await asyncio.sleep(0.01)
            refreshed = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
            if refreshed is not None and [
                document.content for document in refreshed.search("shared", max_results=5)
            ] == ["shared local new"]:
                break
        else:
            pytest.fail("background on-access refresh did not publish the edited local source")
    finally:
        await scheduler.shutdown()


@pytest.mark.asyncio
async def test_shared_local_watch_schedule_refresh_on_access_is_throttled(tmp_path: Path) -> None:
    """A freshly refreshed local watch=true base stays READY during refresh-on-access cooldown."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("shared local old", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"], watch=True)
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    unavailable_details: dict[str, KnowledgeAvailabilityDetail] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    unavailable_details.update(_resolution.unavailable)
    assert _resolution.knowledge is not None
    assert unavailable == {"docs": KnowledgeAvailability.STALE}
    assert unavailable_details == {
        "docs": KnowledgeAvailabilityDetail(
            availability=KnowledgeAvailability.STALE,
            search_available=True,
        ),
    }

    doc.write_text("shared local new", encoding="utf-8")
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    unavailable.clear()
    unavailable_details.clear()
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    unavailable_details.update(_resolution.unavailable)
    refreshed_knowledge = _resolution.knowledge

    assert refreshed_knowledge is not None
    assert [document.content for document in refreshed_knowledge.search("shared", max_results=5)] == [
        "shared local new",
    ]
    assert unavailable == {}
    assert unavailable_details == {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    unavailable_details.update(_resolution.unavailable)
    assert _resolution.knowledge is not None
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    unavailable_details.update(_resolution.unavailable)
    assert _resolution.knowledge is not None
    assert unavailable == {}
    assert unavailable_details == {}

    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_config_mode_round_trip_marks_semantic_index_stale_after_file_mode_edits(tmp_path: Path) -> None:
    """Config-only mode transitions must not silently revive stale semantic indexes."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("semantic old", encoding="utf-8")
    semantic_config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"], watch=True)
    file_config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        watch=True,
        modes={"docs": "files"},
    )
    runtime_paths = test_runtime_paths(tmp_path)
    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, semantic_config)

    await refresh_knowledge_binding("docs", config=semantic_config, runtime_paths=runtime_paths)
    ready_lookup = get_published_index("docs", config=semantic_config, runtime_paths=runtime_paths)
    assert ready_lookup.availability is KnowledgeAvailability.READY

    client = TestClient(main.app)
    response = client.put("/api/config/save", json=file_config.authored_model_dump())
    assert response.status_code == 200
    doc.write_text("semantic new", encoding="utf-8")
    response = client.put("/api/config/save", json=semantic_config.authored_model_dump())
    assert response.status_code == 200

    current_config, current_runtime_paths = config_lifecycle.read_app_committed_runtime_config(main.app)
    stale_lookup = get_published_index("docs", config=current_config, runtime_paths=current_runtime_paths)

    assert stale_lookup.availability is KnowledgeAvailability.STALE
    assert stale_lookup.state is not None
    assert published_index_refresh_state(stale_lookup.state) == "stale"


@pytest.mark.asyncio
async def test_shared_local_watch_file_event_marks_stale_and_schedules_refresh(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Filesystem watch events should preserve last-good reads and refresh in the background."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("watch old", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"], watch=True)
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    event_delivered = asyncio.Event()

    async def _fake_awatch(
        *_paths: Path,
        stop_event: asyncio.Event,
        **_kwargs: object,
    ) -> AsyncIterator[set[tuple[Change, str]]]:
        yield {(Change.modified, str(doc))}
        event_delivered.set()
        await stop_event.wait()

    monkeypatch.setattr("mindroom.knowledge.watch.awatch", _fake_awatch)
    refresh_scheduler = MagicMock()
    source_watcher = KnowledgeSourceWatcher(refresh_scheduler)

    await source_watcher.sync(config=config, runtime_paths=runtime_paths)
    await asyncio.wait_for(event_delivered.wait(), timeout=1)
    await source_watcher.shutdown()

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    unavailable_details: dict[str, KnowledgeAvailabilityDetail] = {}

    assert state is not None
    assert published_index_refresh_state(state) == "stale"
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
    )
    unavailable_details.update(_resolution.unavailable)
    assert _resolution.knowledge is not None
    refresh_scheduler.schedule_refresh.assert_called_once()
    assert refresh_scheduler.schedule_refresh.call_args.args == ("docs",)
    assert unavailable_details == {
        "docs": KnowledgeAvailabilityDetail(
            availability=KnowledgeAvailability.STALE,
            search_available=True,
        ),
    }


@pytest.mark.asyncio
async def test_git_knowledge_polling_waits_before_startup_refresh(tmp_path: Path) -> None:
    """Shared Git bases should not burst refresh work immediately when runtime support starts."""
    docs_path = tmp_path / "docs"
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", poll_interval_seconds=5)
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
        watch=False,
    )
    runtime_paths = runtime_paths_for(config)
    refresh_scheduler = MagicMock()
    source_watcher = KnowledgeSourceWatcher(refresh_scheduler)

    await source_watcher.sync(config=config, runtime_paths=runtime_paths)
    try:
        await asyncio.sleep(0)
    finally:
        await source_watcher.shutdown()

    refresh_scheduler.schedule_refresh.assert_not_called()


@pytest.mark.asyncio
async def test_git_knowledge_polling_repeats_after_poll_interval(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Shared Git bases should keep scheduling refreshes on their configured poll interval."""
    docs_path = tmp_path / "docs"
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", poll_interval_seconds=5)
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
        watch=False,
    )
    runtime_paths = runtime_paths_for(config)
    second_schedule = asyncio.Event()
    refresh_scheduler = MagicMock()

    def _record_schedule(*_args: object, **_kwargs: object) -> None:
        if refresh_scheduler.schedule_refresh.call_count == 2:
            second_schedule.set()

    refresh_scheduler.schedule_refresh.side_effect = _record_schedule
    wait_calls = 0

    async def _fake_wait_for(awaitable: Coroutine[object, object, object], **kwargs: object) -> object:
        nonlocal wait_calls
        assert kwargs == {"timeout": 5.0}
        wait_calls += 1
        if wait_calls <= 2:
            awaitable.close()
            raise TimeoutError
        return await awaitable

    monkeypatch.setattr("mindroom.knowledge.watch.asyncio.wait_for", _fake_wait_for)
    source_watcher = KnowledgeSourceWatcher(refresh_scheduler)

    await source_watcher.sync(config=config, runtime_paths=runtime_paths)
    try:
        for _attempt in range(50):
            if second_schedule.is_set():
                break
            await asyncio.sleep(0)
        else:
            pytest.fail("Git poller did not schedule refresh after interval")
    finally:
        await source_watcher.shutdown()

    assert refresh_scheduler.schedule_refresh.call_count == 2
    assert wait_calls >= 2
    assert [call.args for call in refresh_scheduler.schedule_refresh.call_args_list] == [("docs",), ("docs",)]


@pytest.mark.asyncio
async def test_schedule_refresh_on_access_reports_stale_while_scheduler_is_active(tmp_path: Path) -> None:
    """Due refresh-on-access remains visible as STALE even when the scheduler already has work active."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("active refresh old", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"], watch=True)
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=True)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    unavailable_details: dict[str, KnowledgeAvailabilityDetail] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    unavailable_details.update(_resolution.unavailable)
    knowledge = _resolution.knowledge

    assert knowledge is not None
    assert [document.content for document in knowledge.search("active", max_results=5)] == ["active refresh old"]
    assert unavailable == {"docs": KnowledgeAvailability.STALE}
    assert unavailable_details == {
        "docs": KnowledgeAvailabilityDetail(
            availability=KnowledgeAvailability.STALE,
            search_available=True,
        ),
    }
    scheduler.schedule_refresh.assert_not_called()


@pytest.mark.asyncio
async def test_stale_index_metadata_schedules_refresh_without_source_scan(tmp_path: Path) -> None:
    """Ready access only uses persisted metadata/source change markers, not request-time source scans."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("ready index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("ready index changed", encoding="utf-8")
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_stale(key, reason="test_stale")
    knowledge_registry._published_indexes.clear()
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    second_knowledge = _resolution.knowledge

    assert knowledge is not None
    assert second_knowledge is not None
    assert [document.content for document in knowledge.search("index", max_results=5)] == ["ready index"]
    assert unavailable == {"docs": KnowledgeAvailability.STALE}
    scheduler.schedule_refresh.assert_called_once()
    assert scheduler.schedule_refresh.call_args.args == ("docs",)


@pytest.mark.asyncio
async def test_stale_index_skips_duplicate_refresh_when_scheduler_is_active(tmp_path: Path) -> None:
    """A stale index should not queue another refresh while the scheduler is already active."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("ready index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("ready index changed", encoding="utf-8")
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_stale(key, reason="test_stale")
    knowledge_registry._published_indexes.clear()
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=True)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert knowledge is not None
    assert [document.content for document in knowledge.search("index", max_results=5)] == ["ready index"]
    assert unavailable == {"docs": KnowledgeAvailability.STALE}
    scheduler.is_refreshing.assert_called_once()
    scheduler.schedule_refresh.assert_not_called()


@pytest.mark.asyncio
async def test_dashboard_delete_keeps_last_good_best_effort_until_refresh(tmp_path: Path) -> None:
    """A dashboard delete marks stale but old vectors can remain visible until refresh publishes."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("deleted secret", encoding="utf-8")
    (docs_path / "keep.md").write_text("kept public", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    initial_knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
    assert initial_knowledge is not None
    assert {document.content for document in initial_knowledge.search("anything", max_results=5)} == {
        "deleted secret",
        "kept public",
    }

    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = scheduler
    try:
        response = TestClient(main.app).delete("/api/knowledge/bases/docs/files/guide.md")
    finally:
        config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = None
    assert response.status_code == 200

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(
        key,
        error="refresh failed after delete",
    )
    stale_knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge

    assert stale_knowledge is not None
    assert {document.content for document in stale_knowledge.search("anything", max_results=5)} == {
        "deleted secret",
        "kept public",
    }
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_dashboard_replacement_upload_keeps_last_good_best_effort_until_refresh(tmp_path: Path) -> None:
    """A replacement upload marks stale but old vectors can remain visible until refresh publishes."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("replaced secret", encoding="utf-8")
    (docs_path / "keep.md").write_text("kept public", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    initial_knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
    assert initial_knowledge is not None
    assert {document.content for document in initial_knowledge.search("anything", max_results=5)} == {
        "replaced secret",
        "kept public",
    }

    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = scheduler
    try:
        response = TestClient(main.app).post(
            "/api/knowledge/bases/docs/upload",
            files=[("files", ("guide.md", b"replacement content", "text/markdown"))],
        )
    finally:
        config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = None
    assert response.status_code == 200

    pending_knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
    assert pending_knowledge is not None
    assert {document.content for document in pending_knowledge.search("anything", max_results=5)} == {
        "replaced secret",
        "kept public",
    }

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(
        key,
        error="refresh failed after replacement",
    )
    filtered_knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge

    assert (docs_path / "guide.md").read_text(encoding="utf-8") == "replacement content"
    assert filtered_knowledge is not None
    assert {document.content for document in filtered_knowledge.search("anything", max_results=5)} == {
        "replaced secret",
        "kept public",
    }
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_dashboard_delete_stale_write_failure_keeps_best_effort_source_change(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Stale metadata failures schedule refresh instead of stranding source deletes."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("restored public", encoding="utf-8")
    config = _config(tmp_path, bases={"research": docs_path, "summary": docs_path}, agent_bases=["research", "summary"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("research", config=config, runtime_paths=runtime_paths)
    await refresh_knowledge_binding("summary", config=config, runtime_paths=runtime_paths)
    original_save_stale = knowledge_registry.mark_published_index_stale
    stale_write_count = 0

    def _fail_second_stale_write(*args: object, **kwargs: object) -> None:
        nonlocal stale_write_count
        stale_write_count += 1
        if stale_write_count == 2:
            msg = "same-source stale write failed"
            raise RuntimeError(msg)
        original_save_stale(*args, **kwargs)

    monkeypatch.setattr(knowledge_registry, "mark_published_index_stale", _fail_second_stale_write)
    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = scheduler
    try:
        with pytest.raises(RuntimeError, match="same-source stale write failed"):
            TestClient(main.app).delete("/api/knowledge/bases/research/files/guide.md")
    finally:
        config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = None

    assert stale_write_count == 2
    assert not (docs_path / "guide.md").exists()
    for base_id in ("research", "summary"):
        lookup = get_published_index(base_id, config=config, runtime_paths=runtime_paths)
        assert lookup.index is not None
        assert [document.content for document in lookup.index.knowledge.search("anything", max_results=5)] == [
            "restored public",
        ]
    assert scheduler.schedule_refresh.call_count == 2
    assert [call.args for call in scheduler.schedule_refresh.call_args_list] == [("research",), ("summary",)]


@pytest.mark.asyncio
async def test_ready_index_access_never_recomputes_source_signature(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """READY request lookup must not walk the corpus to recompute source signatures."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("ready index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.source_signature is not None

    def _unexpected_signature(*_args: object, **_kwargs: object) -> str:
        msg = "READY request lookup must not recompute knowledge source signatures"
        raise AssertionError(msg)

    monkeypatch.setattr("mindroom.knowledge.manager._knowledge_source_signature", _unexpected_signature)

    assert resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge is not None
    assert resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge is not None


def test_knowledge_file_listing_rejects_symlink_file_escape(tmp_path: Path) -> None:
    """A symlinked file inside the KB must not expose files outside the knowledge root."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    secret = tmp_path / "secret.md"
    secret.write_text("secret outside root", encoding="utf-8")
    try:
        (docs_path / "leak.md").symlink_to(secret)
    except OSError as exc:
        pytest.skip(f"symlinks unavailable: {exc}")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    assert list_knowledge_files(config, "docs", docs_path) == []


def test_knowledge_file_listing_rejects_symlinked_directory_escape(tmp_path: Path) -> None:
    """Traversal must not follow symlinked directories out of the knowledge root."""
    docs_path = tmp_path / "docs"
    outside = tmp_path / "outside"
    docs_path.mkdir()
    outside.mkdir()
    (outside / "secret.md").write_text("secret through directory", encoding="utf-8")
    try:
        (docs_path / "linked").symlink_to(outside, target_is_directory=True)
    except OSError as exc:
        pytest.skip(f"symlinks unavailable: {exc}")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    assert list_knowledge_files(config, "docs", docs_path) == []


def test_tracked_path_listing_skips_per_file_strict_resolution(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Chain-vetted candidates must not pay a strict resolve walk per file.

    ``resolve(strict=True)`` re-walks every path component and ignores the
    directory guard's symlink cache, so on a network filesystem it turns one
    listing pass into several round trips per file.
    """
    docs_path = tmp_path / "docs"
    nested = docs_path / "guide"
    nested.mkdir(parents=True)
    (docs_path / "root.md").write_text("root", encoding="utf-8")
    (nested / "deep.md").write_text("deep", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    original_resolve = Path.resolve

    def _resolve(self: Path, *args: object, **kwargs: object) -> Path:
        strict = bool(args[0]) if args else bool(kwargs.get("strict", False))
        if strict:
            msg = "chain-vetted candidates must not be strictly resolved per file"
            raise AssertionError(msg)
        return original_resolve(self, *args, **kwargs)

    monkeypatch.setattr(Path, "resolve", _resolve)

    files = knowledge_files_from_relative_paths(config, "docs", docs_path, ["root.md", "guide/deep.md"])

    assert sorted(path.name for path in files) == ["deep.md", "root.md"]


def test_tracked_path_listing_rejects_symlinked_file_escape(tmp_path: Path) -> None:
    """A symlinked tracked path must not expose files outside the knowledge root."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    secret = tmp_path / "secret.md"
    secret.write_text("secret outside root", encoding="utf-8")
    try:
        (docs_path / "leak.md").symlink_to(secret)
    except OSError as exc:
        pytest.skip(f"symlinks unavailable: {exc}")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    assert knowledge_files_from_relative_paths(config, "docs", docs_path, ["leak.md"]) == []


def test_tracked_path_listing_rejects_symlinked_directory_escape(tmp_path: Path) -> None:
    """A tracked path reached through a symlinked directory must stay excluded."""
    docs_path = tmp_path / "docs"
    outside = tmp_path / "outside"
    docs_path.mkdir()
    outside.mkdir()
    (outside / "secret.md").write_text("secret through directory", encoding="utf-8")
    try:
        (docs_path / "linked").symlink_to(outside, target_is_directory=True)
    except OSError as exc:
        pytest.skip(f"symlinks unavailable: {exc}")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    assert knowledge_files_from_relative_paths(config, "docs", docs_path, ["linked/secret.md"]) == []


def test_bases_endpoint_counts_files_without_building_the_file_listing(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The base list reports a count, so it must not build the whole file payload.

    ``_list_file_info`` stats every managed file a second time (the listing itself
    already checked each one) and materializes a dict per file. Both scale with the
    corpus on every request, and ``/bases`` uses none of it beyond the count;
    ``/bases/{base_id}/files`` still serves the full listing.
    """
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    for index in range(3):
        (docs_path / f"doc{index}.md").write_text("body", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    async def _unexpected_list_file_info(*_args: object, **_kwargs: object) -> object:
        msg = "the base list must not build the full file listing"
        raise AssertionError(msg)

    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    monkeypatch.setattr(knowledge_api, "_list_file_info", _unexpected_list_file_info)
    response = TestClient(main.app).get("/api/knowledge/bases")

    assert response.status_code == 200
    entry = next(base for base in response.json()["bases"] if base["name"] == "docs")
    assert entry["file_count"] == 3
    assert entry["file_listing_degraded"] is False


def test_base_files_endpoint_still_returns_sizes_and_timestamps(tmp_path: Path) -> None:
    """The dedicated listing endpoint keeps the per-file detail the base list dropped."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("body", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    response = TestClient(main.app).get("/api/knowledge/bases/docs/files")

    assert response.status_code == 200
    payload = response.json()
    assert payload["file_count"] == 1
    assert payload["total_size"] == len(b"body")
    assert payload["files"][0]["path"] == "doc.md"
    assert payload["files"][0]["size"] == len(b"body")
    assert payload["files"][0]["modified"]


def test_directory_guard_rejects_parent_traversal(tmp_path: Path) -> None:
    """The guard must reject "..", which pathlib's lexical ``relative_to`` lets through.

    This is the containment control that replaced ``resolve(strict=True)``. Without
    it a "../*.md" include pattern yields a listing target at the parent directory
    whose candidates pass every remaining per-file safety check.
    """
    root = tmp_path / "docs"
    root.mkdir()
    guard = knowledge_file_listing_module._DirectoryGuard(root=root)

    assert guard.is_safe(root) is True
    assert guard.is_safe(root / "..") is False
    assert guard.is_safe(root / ".." / "..") is False
    assert guard.is_safe(root / "nested" / ".." / ".." / "outside") is False


def test_tracked_path_listing_rejects_parent_traversal_escape(tmp_path: Path) -> None:
    """A tracked relative path that walks out of the knowledge root must stay excluded."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (tmp_path / "secret.md").write_text("secret outside root", encoding="utf-8")
    (docs_path / "kept.md").write_text("kept", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    files = knowledge_files_from_relative_paths(config, "docs", docs_path, ["kept.md", "../secret.md"])

    assert [path.name for path in files] == ["kept.md"]


def test_knowledge_base_config_rejects_parent_traversal_patterns() -> None:
    """Config validation is the first containment layer, so the guard is never reached this way."""
    with pytest.raises(ValidationError):
        KnowledgeBaseConfig(path="./docs", include_patterns=["../*.md"])


def test_tracked_path_listing_rejects_directories_and_missing_paths(tmp_path: Path) -> None:
    """Only regular files survive the tracked-path safety checks."""
    docs_path = tmp_path / "docs"
    (docs_path / "directory.md").mkdir(parents=True)
    (docs_path / "kept.md").write_text("kept", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    files = knowledge_files_from_relative_paths(config, "docs", docs_path, ["kept.md", "directory.md", "gone.md"])

    assert [path.name for path in files] == ["kept.md"]


def test_knowledge_file_listing_skips_hidden_files_for_directory_bases(tmp_path: Path) -> None:
    """Dot-prefixed entries (e.g. in-place writers' atomic-write temp files) stay out of directory bases."""
    docs_path = tmp_path / "docs"
    (docs_path / ".staging").mkdir(parents=True)
    (docs_path / "kept.md").write_text("kept", encoding="utf-8")
    (docs_path / ".hidden.md").write_text("hidden", encoding="utf-8")
    (docs_path / ".staging" / "nested.md").write_text("nested", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    assert list_knowledge_files(config, "docs", docs_path) == [(docs_path / "kept.md").resolve()]

    config.knowledge_bases["docs"].skip_hidden = False
    assert list_knowledge_files(config, "docs", docs_path) == sorted(
        [
            (docs_path / "kept.md").resolve(),
            (docs_path / ".hidden.md").resolve(),
            (docs_path / ".staging" / "nested.md").resolve(),
        ],
    )


@pytest.mark.asyncio
async def test_reindex_files_locked_records_files_vanishing_during_refresh(tmp_path: Path) -> None:
    """A file deleted between listing and indexing is skipped instead of failing the refresh.

    Live source folders such as thread exports delete files while a refresh
    runs (stale-thread cleanup); the per-file stat used to raise
    FileNotFoundError and abort the whole reindex subprocess.
    """
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths_for(config))

    vanished = (docs_path / "gone.md").resolve()
    vanished_files: set[str] = set()
    indexed_signatures: dict[str, tuple[int, int, str]] = {}
    indexed = await manager._reindex_files_locked(
        [vanished],
        knowledge=manager._knowledge,
        indexed_signatures=indexed_signatures,
        vanished_files=vanished_files,
    )
    assert indexed == 0
    assert indexed_signatures == {}
    assert vanished_files == {"gone.md"}


@pytest.mark.asyncio
async def test_reindex_publishes_surviving_files_when_one_vanishes_mid_refresh(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A file deleted between listing and indexing must not mark the refresh incomplete.

    The surviving corpus matches the live folder, so the refresh publishes it;
    only genuine indexing failures may abort the pass.
    """
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "kept.md").write_text("survives the refresh", encoding="utf-8")
    doomed = (docs_path / "doomed.md").resolve()
    doomed.write_text("deleted mid-refresh", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths_for(config))

    original_signature = KnowledgeManager._file_signature

    def vanishing_signature(self: KnowledgeManager, file_path: Path) -> object:
        if file_path == doomed:
            doomed.unlink(missing_ok=True)
        return original_signature(self, file_path)

    monkeypatch.setattr(KnowledgeManager, "_file_signature", vanishing_signature)

    assert await manager.reindex_all() == RefreshOutcome(indexed_count=1, published=True, error=None)
    assert manager._has_vectors_for_source_path("kept.md", knowledge=manager._knowledge)
    assert not manager._has_vectors_for_source_path("doomed.md", knowledge=manager._knowledge)


def test_knowledge_file_listing_filters_unsupported_extensions_before_filesystem_safety_checks(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Unsupported files should not pay the per-file filesystem safety check."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    ignored_path = docs_path / "ignored.bin"
    ignored_path.write_bytes(b"not semantic")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    original_lstat = Path.lstat

    def _lstat(self: Path, *args: object, **kwargs: object) -> os.stat_result:
        if self.name == ignored_path.name:
            msg = "unsupported files should be filtered before the safety check"
            raise AssertionError(msg)
        return original_lstat(self, *args, **kwargs)

    monkeypatch.setattr(Path, "lstat", _lstat)

    assert list_knowledge_files(config, "docs", docs_path) == []


def test_local_knowledge_file_listing_prunes_literal_include_prefixes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Local include globs should avoid walking unrelated source trees."""
    docs_path = tmp_path / "workspace"
    memory_dir = docs_path / "memory"
    unrelated_dir = docs_path / "docs" / "deep"
    memory_dir.mkdir(parents=True)
    unrelated_dir.mkdir(parents=True)
    memory_file = memory_dir / "2026-06-02.md"
    unrelated_file = unrelated_dir / "runbook.md"
    memory_file.write_text("Indexed memory.\n", encoding="utf-8")
    unrelated_file.write_text("Unrelated markdown.\n", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    config.knowledge_bases["docs"] = KnowledgeBaseConfig(
        path=str(docs_path),
        include_extensions=[".md"],
        include_patterns=["memory/**/*.md"],
    )

    walked_roots: list[Path] = []
    original_walk = knowledge_file_listing_module.os.walk

    def recording_walk(top: object, *args: object, **kwargs: object) -> object:
        walked_roots.append(Path(top))
        return original_walk(top, *args, **kwargs)

    monkeypatch.setattr(knowledge_file_listing_module.os, "walk", recording_walk)

    files = list_knowledge_files(config, "docs", docs_path)

    assert files == [memory_file.resolve()]
    assert walked_roots == [memory_dir.resolve()]


def test_extra_extensions_extend_default_semantic_set(tmp_path: Path) -> None:
    """extra_extensions add to the default text-like set without replacing it."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "notes.md").write_text("hello", encoding="utf-8")
    (docs_path / "slides.pptx").write_bytes(b"fake deck")
    (docs_path / "blob.bin").write_bytes(b"binary")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    config.knowledge_bases["docs"] = KnowledgeBaseConfig(
        path=str(docs_path),
        extra_extensions=[".pptx"],
    )

    files = list_knowledge_files(config, "docs", docs_path)

    assert files == [(docs_path / "notes.md").resolve(), (docs_path / "slides.pptx").resolve()]


def test_extra_extensions_compose_with_include_and_exclude_extensions(tmp_path: Path) -> None:
    """include_extensions stay an exact set, extras add on top, excludes win last."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "notes.md").write_text("hello", encoding="utf-8")
    (docs_path / "readme.txt").write_text("hello", encoding="utf-8")
    (docs_path / "slides.pptx").write_bytes(b"fake deck")
    (docs_path / "report.pdf").write_bytes(b"fake report")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    config.knowledge_bases["docs"] = KnowledgeBaseConfig(
        path=str(docs_path),
        include_extensions=[".md"],
        extra_extensions=[".pptx", ".pdf"],
        exclude_extensions=[".pdf"],
    )

    files = list_knowledge_files(config, "docs", docs_path)

    assert files == [(docs_path / "notes.md").resolve(), (docs_path / "slides.pptx").resolve()]


@pytest.mark.asyncio
async def test_reindex_skips_files_whose_reader_dependency_is_missing(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A missing reader package skips the file loudly instead of failing the refresh."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "notes.md").write_text("indexed text", encoding="utf-8")
    (docs_path / "slides.pptx").write_text("fake deck", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    config.knowledge_bases["docs"] = KnowledgeBaseConfig(
        path=str(docs_path),
        extra_extensions=[".pptx"],
    )
    original_get_reader = knowledge_manager_module.ReaderFactory.get_reader_for_extension

    def failing_get_reader(extension: str) -> object:
        if extension == ".pptx":
            msg = "The `python-pptx` package is not installed."
            raise ImportError(msg)
        return original_get_reader(extension)

    monkeypatch.setattr(knowledge_manager_module.ReaderFactory, "get_reader_for_extension", failing_get_reader)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths_for(config))

    assert await manager.reindex_all() == RefreshOutcome(
        indexed_count=1,
        published=False,
        error="Indexed 1 of 2 managed knowledge files",
    )


@pytest.mark.asyncio
async def test_index_metadata_without_source_signature_is_unavailable_and_schedules_refresh(
    tmp_path: Path,
) -> None:
    """Stale-format published metadata is treated as corrupt instead of interpreted."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("stale-format index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    metadata_path = published_index_metadata_path(key)
    payload = json.loads(metadata_path.read_text(encoding="utf-8"))
    payload.pop("source_signature", None)
    metadata_path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
    knowledge_registry._published_indexes.clear()
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert knowledge is None
    assert unavailable == {"docs": KnowledgeAvailability.REFRESH_FAILED}
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_successful_publish_clears_stale_refresh_state(tmp_path: Path) -> None:
    """A successful publish clears stale refresh state."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_stale(key, reason="test_stale")

    stale_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert stale_lookup.index is not None
    assert stale_lookup.availability is KnowledgeAvailability.STALE

    (docs_path / "doc.md").write_text("index updated", encoding="utf-8")
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))

    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert knowledge is not None
    assert state is not None
    assert knowledge_registry.published_index_refresh_state(state) == "none"
    assert state.refresh_job == "idle"
    assert unavailable == {}


@pytest.mark.asyncio
async def test_refreshing_state_cancellation_clears_active_refresh_count(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Cancellation during the initial refreshing state write must not leak active status."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    loop = asyncio.get_running_loop()
    refreshing_write_started = asyncio.Event()
    release_refreshing_write = Event()

    def _blocked_refreshing_state(*_args: object, **_kwargs: object) -> None:
        loop.call_soon_threadsafe(refreshing_write_started.set)
        assert release_refreshing_write.wait(timeout=5)

    monkeypatch.setattr(knowledge_refresh_runner, "mark_published_index_refresh_running", _blocked_refreshing_state)

    refresh_task = asyncio.create_task(refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths))
    await refreshing_write_started.wait()
    refresh_task.cancel()
    release_refreshing_write.set()
    with pytest.raises(asyncio.CancelledError):
        await refresh_task

    refresh_target = knowledge_registry.resolve_refresh_target("docs", config=config, runtime_paths=runtime_paths)
    assert knowledge_refresh_locks.is_refresh_active(refresh_target) is False


@pytest.mark.asyncio
async def test_cancelled_refresh_after_refreshing_write_keeps_existing_index_stale(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Cancellation after the refreshing state write must not clear stale index state."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "guide.md"
    doc.write_text("stable index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_stale(key, reason="source_changed")

    loop = asyncio.get_running_loop()
    refreshing_saved = asyncio.Event()
    release_refreshing_save = Event()
    original_save_refreshing = knowledge_refresh_runner.mark_published_index_refresh_running

    def _block_after_refreshing_state(*args: object, **kwargs: object) -> None:
        original_save_refreshing(*args, **kwargs)
        loop.call_soon_threadsafe(refreshing_saved.set)
        assert release_refreshing_save.wait(timeout=5)

    monkeypatch.setattr(
        knowledge_refresh_runner,
        "mark_published_index_refresh_running",
        _block_after_refreshing_state,
    )

    refresh_task = asyncio.create_task(
        refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths),
    )
    await refreshing_saved.wait()
    refresh_task.cancel()
    release_refreshing_save.set()
    with pytest.raises(asyncio.CancelledError):
        await refresh_task

    state = load_published_index_state(published_index_metadata_path(key))
    assert knowledge_registry.published_index_refresh_state(state) == "stale"
    assert state is not None
    assert state.refresh_job == "idle"


@pytest.mark.asyncio
async def test_cancelled_refresh_waiting_for_source_lock_does_not_touch_running_state(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Cancellation while queued behind another refresh must not mutate refresh metadata."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("locked refresh", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    refreshing_write_count = 0
    first_entered = asyncio.Event()
    release_first = asyncio.Event()
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    source_root = knowledge_registry.source_root_for_published_index_key(key)
    original_save_refreshing = knowledge_refresh_runner.mark_published_index_refresh_running
    original_reindex = KnowledgeManager.reindex_all

    def _track_refreshing_state(*args: object, **kwargs: object) -> None:
        nonlocal refreshing_write_count
        original_save_refreshing(*args, **kwargs)
        refreshing_write_count += 1

    async def _blocked_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        _ = force_reindex
        first_entered.set()
        await release_first.wait()
        return await original_reindex(self, force_reindex=force_reindex)

    monkeypatch.setattr(knowledge_refresh_runner, "mark_published_index_refresh_running", _track_refreshing_state)
    monkeypatch.setattr(KnowledgeManager, "reindex_all", _blocked_reindex)

    first_task = asyncio.create_task(refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths))
    await first_entered.wait()
    second_task = asyncio.create_task(refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths))
    await _wait_for_refresh_lock_borrowers(source_root, 2)

    second_task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await second_task

    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.refresh_job == "running"
    assert refreshing_write_count == 1

    release_first.set()
    await first_task


@pytest.mark.asyncio
async def test_cancelled_source_lock_waiter_does_not_wedge_later_mutation(tmp_path: Path) -> None:
    """A cancelled queued waiter must not acquire and leak the source lock."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    refresh_target = knowledge_registry.resolve_refresh_target("docs", config=config, runtime_paths=runtime_paths)
    source_root = knowledge_registry.source_root_for_refresh_target(refresh_target)
    holder_entered = asyncio.Event()
    release_holder = asyncio.Event()
    waiter_entered = asyncio.Event()

    async def _hold_lock() -> None:
        async with knowledge_binding_mutation_lock("docs", config=config, runtime_paths=runtime_paths):
            holder_entered.set()
            await release_holder.wait()

    async def _queued_waiter() -> None:
        async with knowledge_binding_mutation_lock("docs", config=config, runtime_paths=runtime_paths):
            waiter_entered.set()

    holder_task = asyncio.create_task(_hold_lock())
    await holder_entered.wait()
    waiter_task = asyncio.create_task(_queued_waiter())
    await _wait_for_refresh_lock_borrowers(source_root, 2)

    waiter_task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await waiter_task

    release_holder.set()
    await holder_task

    async with asyncio.timeout(1):
        async with knowledge_binding_mutation_lock("docs", config=config, runtime_paths=runtime_paths):
            pass
    assert not waiter_entered.is_set()


@pytest.mark.asyncio
async def test_refresh_lock_pruning_keeps_queued_waiter_entry(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Pruning must not drop a lock entry with an active queued waiter."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    refresh_target = knowledge_registry.resolve_refresh_target("docs", config=config, runtime_paths=runtime_paths)
    source_root = knowledge_registry.source_root_for_refresh_target(refresh_target)
    holder_entered = asyncio.Event()
    release_holder = asyncio.Event()
    waiter_entered = asyncio.Event()
    monkeypatch.setattr(knowledge_refresh_locks, "_MAX_REFRESH_LOCKS", 1)

    async def _hold_lock() -> None:
        async with knowledge_binding_mutation_lock("docs", config=config, runtime_paths=runtime_paths):
            holder_entered.set()
            await release_holder.wait()

    async def _queued_waiter() -> None:
        async with knowledge_binding_mutation_lock("docs", config=config, runtime_paths=runtime_paths):
            waiter_entered.set()

    holder_task = asyncio.create_task(_hold_lock())
    await holder_entered.wait()
    waiter_task = asyncio.create_task(_queued_waiter())
    await _wait_for_refresh_lock_borrowers(source_root, 2)
    original_entry = knowledge_refresh_locks._refresh_locks[source_root]

    for index in range(5):
        _create_idle_refresh_lock(
            knowledge_registry.KnowledgeSourceRoot(
                storage_root=str(tmp_path / f"other-{index}"),
                knowledge_path=str(tmp_path / f"other-{index}" / "docs"),
            ),
        )

    assert knowledge_refresh_locks._refresh_locks.get(source_root) is original_entry

    release_holder.set()
    async with asyncio.timeout(1):
        await asyncio.gather(holder_task, waiter_task)
    assert waiter_entered.is_set()


@pytest.mark.asyncio
async def test_source_root_lock_takes_the_in_loop_half_before_the_cross_process_half(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The in-loop half must nest outside the file lock, so the two unwind in reverse."""
    source_root = knowledge_registry.KnowledgeSourceRoot(
        storage_root=str(tmp_path),
        knowledge_path=str(tmp_path / "docs"),
    )
    events: list[str] = []

    def _recorder(half: str) -> Callable[[knowledge_registry.KnowledgeSourceRoot], AbstractAsyncContextManager[None]]:
        @asynccontextmanager
        async def _record(key: knowledge_registry.KnowledgeSourceRoot) -> AsyncIterator[None]:
            assert key == source_root
            events.append(f"acquire {half}")
            try:
                yield
            finally:
                events.append(f"release {half}")

        return _record

    monkeypatch.setattr(knowledge_refresh_locks, "_acquire_refresh_lock", _recorder("in_loop"))
    monkeypatch.setattr(knowledge_refresh_locks, "_acquire_refresh_file_lock", _recorder("file"))

    async with knowledge_refresh_locks.refresh_source_root_lock(source_root):
        events.append("body")

    assert events == ["acquire in_loop", "acquire file", "body", "release file", "release in_loop"]


@pytest.mark.asyncio
async def test_cancelling_while_the_cross_process_half_is_pending_frees_the_in_loop_half(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Taking the pair in two stages must not strand the first half when the second is cancelled."""
    source_root = knowledge_registry.KnowledgeSourceRoot(
        storage_root=str(tmp_path),
        knowledge_path=str(tmp_path / "docs"),
    )
    file_lock_reached = asyncio.Event()
    release_file_lock = asyncio.Event()

    @asynccontextmanager
    async def _blocked_file_lock(_key: knowledge_registry.KnowledgeSourceRoot) -> AsyncIterator[None]:
        file_lock_reached.set()
        await release_file_lock.wait()
        yield

    monkeypatch.setattr(knowledge_refresh_locks, "_acquire_refresh_file_lock", _blocked_file_lock)

    async def _take_the_pair() -> None:
        async with knowledge_refresh_locks.refresh_source_root_lock(source_root):
            pass

    blocked_task = asyncio.create_task(_take_the_pair())
    await file_lock_reached.wait()
    await _wait_for_refresh_lock_borrowers(source_root, 1)
    entry = knowledge_refresh_locks._refresh_locks[source_root]
    assert entry.lock.locked()

    blocked_task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await blocked_task

    assert not entry.lock.locked()
    assert entry.borrowers == 0


def test_source_changed_updates_refresh_state_without_changing_index(tmp_path: Path) -> None:
    """Source mutation records source changes without mutating published index data."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("published old", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths)
    default_collection = manager._collections.default_collection
    _VectorDb.collections[default_collection] = [
        {"content": "published old", "metadata": {"source_path": "guide.md"}},
    ]
    metadata_path = published_index_metadata_path(key)
    knowledge_registry.save_published_index_state(
        metadata_path,
        knowledge_registry.PublishedIndexState(
            settings=key.indexing_settings,
            status="complete",
            collection=default_collection,
            indexed_count=1,
            source_signature="test-source-signature",
        ),
    )
    knowledge_registry.mark_published_index_refresh_succeeded(key)

    marked_base_ids = knowledge_registry._mark_knowledge_source_changed(
        "docs",
        config=config,
        runtime_paths=runtime_paths,
    )
    state = load_published_index_state(metadata_path)

    assert marked_base_ids == ("docs",)
    assert _VectorDb.collections[default_collection] == [
        {"content": "published old", "metadata": {"source_path": "guide.md"}},
    ]
    assert state is not None
    assert knowledge_registry.published_index_refresh_state(state) == "stale"
    assert state.refresh_job == "pending"


@pytest.mark.asyncio
async def test_mark_stale_fans_out_to_duplicate_physical_sources(tmp_path: Path) -> None:
    """Mutating one base should stale every published index that reads the same source folder."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "guide.md"
    doc.write_text("shared source old", encoding="utf-8")
    config = _config(tmp_path, bases={"alpha": docs_path, "beta": docs_path}, agent_bases=["alpha", "beta"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("alpha", config=config, runtime_paths=runtime_paths)
    await refresh_knowledge_binding("beta", config=config, runtime_paths=runtime_paths)
    beta_lookup = get_published_index("beta", config=config, runtime_paths=runtime_paths)
    assert beta_lookup.index is not None
    assert beta_lookup.availability is KnowledgeAvailability.READY
    doc.write_text("shared source new", encoding="utf-8")

    marked_base_ids = knowledge_registry._mark_knowledge_source_changed(
        "alpha",
        config=config,
        runtime_paths=runtime_paths,
    )
    beta_key = resolve_published_index_key("beta", config=config, runtime_paths=runtime_paths)
    beta_state = load_published_index_state(published_index_metadata_path(beta_key))
    refreshed_beta_lookup = get_published_index("beta", config=config, runtime_paths=runtime_paths)

    assert marked_base_ids == ("alpha", "beta")
    assert beta_state is not None
    assert knowledge_registry.published_index_refresh_state(beta_state) == "stale"
    assert refreshed_beta_lookup.availability is KnowledgeAvailability.STALE


@pytest.mark.asyncio
async def test_mark_stale_skips_file_mode_duplicate_physical_sources(tmp_path: Path) -> None:
    """File-mode aliases do not maintain semantic indexes that need stale marking."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "guide.md"
    doc.write_text("shared source old", encoding="utf-8")
    config = _config(
        tmp_path,
        bases={"alpha": docs_path, "beta": docs_path},
        agent_bases=["alpha", "beta"],
        modes={"alpha": "semantic", "beta": "files"},
    )
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("alpha", config=config, runtime_paths=runtime_paths)
    await refresh_knowledge_binding("beta", config=config, runtime_paths=runtime_paths)
    beta_key = resolve_published_index_key("beta", config=config, runtime_paths=runtime_paths)
    beta_metadata_path = published_index_metadata_path(beta_key)
    assert load_published_index_state(beta_metadata_path) is not None

    doc.write_text("shared source new", encoding="utf-8")
    marked_base_ids = knowledge_registry._mark_knowledge_source_changed(
        "alpha",
        config=config,
        runtime_paths=runtime_paths,
    )
    beta_state = load_published_index_state(beta_metadata_path)

    assert marked_base_ids == ("alpha",)
    assert beta_state is not None
    assert beta_state.status == "complete"
    assert beta_state.collection is None
    assert knowledge_registry.published_index_refresh_state(beta_state) == "none"


@pytest.mark.asyncio
async def test_mark_stale_from_file_mode_alias_marks_semantic_duplicate_sources(tmp_path: Path) -> None:
    """File-mode source mutations should stale semantic aliases that read the same folder."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "guide.md"
    doc.write_text("shared source old", encoding="utf-8")
    config = _config(
        tmp_path,
        bases={"alpha": docs_path, "beta": docs_path},
        agent_bases=["alpha", "beta"],
        modes={"alpha": "semantic", "beta": "files"},
    )
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("alpha", config=config, runtime_paths=runtime_paths)
    await refresh_knowledge_binding("beta", config=config, runtime_paths=runtime_paths)
    alpha_lookup = get_published_index("alpha", config=config, runtime_paths=runtime_paths)
    assert alpha_lookup.index is not None
    assert alpha_lookup.availability is KnowledgeAvailability.READY

    doc.write_text("shared source new", encoding="utf-8")
    marked_base_ids = knowledge_registry._mark_knowledge_source_changed(
        "beta",
        config=config,
        runtime_paths=runtime_paths,
    )
    alpha_key = resolve_published_index_key("alpha", config=config, runtime_paths=runtime_paths)
    beta_key = resolve_published_index_key("beta", config=config, runtime_paths=runtime_paths)
    alpha_state = load_published_index_state(published_index_metadata_path(alpha_key))
    beta_state = load_published_index_state(published_index_metadata_path(beta_key))
    refreshed_alpha_lookup = get_published_index("alpha", config=config, runtime_paths=runtime_paths)

    assert marked_base_ids == ("alpha",)
    assert alpha_state is not None
    assert knowledge_registry.published_index_refresh_state(alpha_state) == "stale"
    assert refreshed_alpha_lookup.availability is KnowledgeAvailability.STALE
    assert beta_state is not None
    assert beta_state.collection is None
    assert knowledge_registry.published_index_refresh_state(beta_state) == "none"


@pytest.mark.asyncio
async def test_async_source_changed_cancellation_waits_for_state_commit(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Cancellation during stale writes must wait for the metadata commit before propagating."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("cached old", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    ready_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert ready_lookup.index is not None
    assert ready_lookup.availability is KnowledgeAvailability.READY

    loop = asyncio.get_running_loop()
    stale_written = asyncio.Event()
    release_stale_write = Event()
    original_mark = knowledge_registry._mark_published_index_key_stale_on_disk

    def _block_after_stale_write(matching_key: knowledge_registry.PublishedIndexKey, *, reason: str) -> bool:
        result = original_mark(matching_key, reason=reason)
        loop.call_soon_threadsafe(stale_written.set)
        assert release_stale_write.wait(timeout=5)
        return result

    monkeypatch.setattr(knowledge_registry, "_mark_published_index_key_stale_on_disk", _block_after_stale_write)

    mark_task = asyncio.create_task(
        knowledge_registry.mark_knowledge_source_changed_async(
            "docs",
            config=config,
            runtime_paths=runtime_paths,
        ),
    )
    await stale_written.wait()
    mark_task.cancel()
    release_stale_write.set()
    with pytest.raises(asyncio.CancelledError):
        await mark_task

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    refreshed_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert state is not None
    assert knowledge_registry.published_index_refresh_state(state) == "stale"
    assert refreshed_lookup.availability is KnowledgeAvailability.STALE


@pytest.mark.asyncio
async def test_async_source_changed_cancellation_finishes_same_source_aliases(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Cancellation after one alias write must still mark every same-source alias stale."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "guide.md"
    doc.write_text("shared cached old", encoding="utf-8")
    config = _config(tmp_path, bases={"alpha": docs_path, "beta": docs_path}, agent_bases=["alpha", "beta"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("alpha", config=config, runtime_paths=runtime_paths)
    await refresh_knowledge_binding("beta", config=config, runtime_paths=runtime_paths)
    doc.write_text("shared cached new", encoding="utf-8")

    loop = asyncio.get_running_loop()
    first_alias_written = asyncio.Event()
    release_remaining_writes = Event()
    original_mark = knowledge_registry._mark_published_index_key_stale_on_disk
    written_base_ids: list[str] = []

    def _block_after_first_alias(matching_key: knowledge_registry.PublishedIndexKey, *, reason: str) -> bool:
        result = original_mark(matching_key, reason=reason)
        written_base_ids.append(matching_key.base_id)
        if len(written_base_ids) == 1:
            loop.call_soon_threadsafe(first_alias_written.set)
            assert release_remaining_writes.wait(timeout=5)
        return result

    monkeypatch.setattr(knowledge_registry, "_mark_published_index_key_stale_on_disk", _block_after_first_alias)

    mark_task = asyncio.create_task(
        knowledge_registry.mark_knowledge_source_changed_async(
            "alpha",
            config=config,
            runtime_paths=runtime_paths,
        ),
    )
    await first_alias_written.wait()
    mark_task.cancel()
    release_remaining_writes.set()
    with pytest.raises(asyncio.CancelledError):
        await mark_task

    alpha_key = resolve_published_index_key("alpha", config=config, runtime_paths=runtime_paths)
    beta_key = resolve_published_index_key("beta", config=config, runtime_paths=runtime_paths)
    alpha_state = load_published_index_state(published_index_metadata_path(alpha_key))
    beta_state = load_published_index_state(published_index_metadata_path(beta_key))

    assert tuple(written_base_ids) == ("alpha", "beta")
    assert knowledge_registry.published_index_refresh_state(alpha_state) == "stale"
    assert knowledge_registry.published_index_refresh_state(beta_state) == "stale"


@pytest.mark.asyncio
async def test_async_source_changed_recached_index_reports_refresh_state_after_write(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Readers may keep last-good handles while refresh state changes to stale after commit."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("recache old", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    ready_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert ready_lookup.index is not None
    assert ready_lookup.availability is KnowledgeAvailability.READY

    loop = asyncio.get_running_loop()
    stale_write_started = asyncio.Event()
    release_stale_write = Event()
    original_mark = knowledge_registry._mark_published_index_key_stale_on_disk

    def _block_before_stale_write(matching_key: knowledge_registry.PublishedIndexKey, *, reason: str) -> bool:
        loop.call_soon_threadsafe(stale_write_started.set)
        assert release_stale_write.wait(timeout=5)
        return original_mark(matching_key, reason=reason)

    monkeypatch.setattr(knowledge_registry, "_mark_published_index_key_stale_on_disk", _block_before_stale_write)

    mark_task = asyncio.create_task(
        knowledge_registry.mark_knowledge_source_changed_async(
            "docs",
            config=config,
            runtime_paths=runtime_paths,
        ),
    )
    await stale_write_started.wait()
    recached_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert recached_lookup.index is not None
    assert recached_lookup.availability is KnowledgeAvailability.READY

    release_stale_write.set()
    assert await mark_task == ("docs",)
    final_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert final_lookup.availability is KnowledgeAvailability.STALE


@pytest.mark.asyncio
async def test_local_refresh_marks_duplicate_source_sibling_stale_after_source_change(tmp_path: Path) -> None:
    """Refreshing one local alias after an external source edit should stale sibling aliases."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "guide.md"
    doc.write_text("shared local old", encoding="utf-8")
    config = _config(tmp_path, bases={"alpha": docs_path, "beta": docs_path}, agent_bases=["alpha", "beta"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("alpha", config=config, runtime_paths=runtime_paths)
    await refresh_knowledge_binding("beta", config=config, runtime_paths=runtime_paths)
    beta_lookup = get_published_index("beta", config=config, runtime_paths=runtime_paths)
    assert beta_lookup.index is not None
    assert beta_lookup.availability is KnowledgeAvailability.READY

    doc.write_text("shared local new", encoding="utf-8")
    await refresh_knowledge_binding("alpha", config=config, runtime_paths=runtime_paths)
    alpha_lookup = get_published_index("alpha", config=config, runtime_paths=runtime_paths)
    beta_key = resolve_published_index_key("beta", config=config, runtime_paths=runtime_paths)
    beta_state = load_published_index_state(published_index_metadata_path(beta_key))
    refreshed_beta_lookup = get_published_index("beta", config=config, runtime_paths=runtime_paths)

    assert alpha_lookup.index is not None
    assert [document.content for document in alpha_lookup.index.knowledge.search("local", max_results=5)] == [
        "shared local new",
    ]
    assert beta_state is not None
    assert knowledge_registry.published_index_refresh_state(beta_state) == "stale"
    assert refreshed_beta_lookup.index is not None
    assert refreshed_beta_lookup.availability is KnowledgeAvailability.STALE
    assert [document.content for document in refreshed_beta_lookup.index.knowledge.search("local", max_results=5)] == [
        "shared local old",
    ]


def test_config_rejects_parent_child_knowledge_roots(tmp_path: Path) -> None:
    """Configured local knowledge roots may be exact aliases, but not overlapping subtrees."""
    parent = tmp_path / "docs"
    child = parent / "nested"

    with pytest.raises(ValueError, match="knowledge_bases paths must not overlap"):
        _config(
            tmp_path,
            bases={"parent": parent, "child": child},
            agent_bases=["parent"],
        )


def test_config_rejects_exact_duplicate_roots_with_mixed_git_ownership(tmp_path: Path) -> None:
    """Exact duplicate knowledge roots must agree on local vs Git source ownership."""
    docs = tmp_path / "docs"

    with pytest.raises(ValueError, match="exact duplicate aliases must use compatible source configuration"):
        _config(
            tmp_path,
            bases={"local": docs, "git": docs},
            agent_bases=["local"],
            git_configs={"git": KnowledgeGitConfig(repo_url="https://example.com/org/repo.git")},
        )


def test_config_rejects_exact_duplicate_git_roots_with_different_source_semantics(tmp_path: Path) -> None:
    """Exact duplicate Git roots must not share one checkout across incompatible source config."""
    docs = tmp_path / "docs"

    with pytest.raises(ValueError, match="exact duplicate aliases must use compatible source configuration"):
        _config(
            tmp_path,
            bases={"main": docs, "release": docs},
            agent_bases=["main"],
            git_configs={
                "main": KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main"),
                "release": KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="release"),
            },
        )


def test_config_rejects_exact_duplicate_git_roots_with_different_passwordless_ssh_usernames(
    tmp_path: Path,
) -> None:
    """Passwordless SSH usernames are part of duplicate-root Git source identity."""
    docs = tmp_path / "docs"

    with pytest.raises(ValueError, match="exact duplicate aliases must use compatible source configuration"):
        _config(
            tmp_path,
            bases={"git_user": docs, "deploy_user": docs},
            agent_bases=["git_user"],
            git_configs={
                "git_user": KnowledgeGitConfig(repo_url="ssh://git@example.com/org/repo.git"),
                "deploy_user": KnowledgeGitConfig(repo_url="ssh://deploy@example.com/org/repo.git"),
            },
        )


def test_config_allows_exact_duplicate_roots_with_compatible_source_semantics(tmp_path: Path) -> None:
    """Exact duplicate aliases remain valid when their source ownership semantics match."""
    docs = tmp_path / "docs"
    git_config = KnowledgeGitConfig(
        repo_url="https://token:secret@example.com/org/repo.git?token=query-secret#fragment-secret",
        branch="main",
        include_patterns=["docs/**"],
        exclude_patterns=["docs/private/**"],
    )

    config = _config(
        tmp_path,
        bases={"alpha": docs, "beta": docs},
        agent_bases=["alpha", "beta"],
        git_configs={
            "alpha": git_config,
            "beta": git_config.model_copy(deep=True),
        },
    )

    assert sorted(config.knowledge_bases) == ["alpha", "beta"]


def test_config_allows_exact_duplicate_git_roots_with_different_filters(tmp_path: Path) -> None:
    """One Git checkout may back multiple filtered knowledge views."""
    docs = tmp_path / "docs"

    config = _config(
        tmp_path,
        bases={"docs": docs, "source": docs},
        agent_bases=["docs", "source"],
        git_configs={
            "docs": KnowledgeGitConfig(
                repo_url="https://example.com/org/repo.git",
                branch="main",
                include_patterns=["docs/**"],
            ),
            "source": KnowledgeGitConfig(
                repo_url="https://example.com/org/repo.git",
                branch="main",
                include_patterns=["src/**"],
            ),
        },
    )

    assert sorted(config.knowledge_bases) == ["docs", "source"]


def test_config_allows_exact_duplicate_local_roots_with_different_filters(tmp_path: Path) -> None:
    """One local folder may back multiple filtered knowledge views."""
    docs = tmp_path / "docs"
    runtime_paths = test_runtime_paths(tmp_path)

    config = bind_runtime_paths(
        Config(
            agents={
                "helper": AgentConfig(
                    display_name="Helper",
                    knowledge_bases=["markdown", "python"],
                ),
            },
            models={},
            knowledge_bases={
                "markdown": KnowledgeBaseConfig(path=str(docs), include_extensions=[".md"]),
                "python": KnowledgeBaseConfig(path=str(docs), include_extensions=[".py"]),
            },
        ),
        runtime_paths,
    )

    assert sorted(config.knowledge_bases) == ["markdown", "python"]


def test_raw_git_url_index_metadata_is_config_mismatch(tmp_path: Path) -> None:
    """Raw Git URLs in persisted settings are stale-format metadata, not a compatible identity."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("raw git metadata", encoding="utf-8")
    raw_repo_url = "https://token:secret@example.com/org/repo.git"
    git_config = KnowledgeGitConfig(repo_url=raw_repo_url)
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    stale_settings = key.indexing_settings.to_metadata()
    stale_settings["repo_identity"] = raw_repo_url
    collection = "raw_git_metadata_collection"
    _VectorDb.collections[collection] = [
        {"content": "raw git metadata", "metadata": {"source_path": "doc.md"}},
    ]
    metadata_path = published_index_metadata_path(key)
    metadata_path.parent.mkdir(parents=True, exist_ok=True)
    metadata_path.write_text(
        json.dumps(
            {
                "settings": stale_settings,
                "status": "complete",
                "collection": collection,
                "indexed_count": 1,
                "source_signature": "test-source-signature",
            },
        ),
        encoding="utf-8",
    )
    knowledge_registry.mark_published_index_refresh_succeeded(key)

    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert lookup.index is None
    assert lookup.availability is KnowledgeAvailability.CONFIG_MISMATCH


def test_passwordless_ssh_username_change_invalidates_published_index(tmp_path: Path) -> None:
    """Passwordless SSH usernames are part of the persisted index identity."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("git user index", encoding="utf-8")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": KnowledgeGitConfig(repo_url="ssh://git@example.com/org/repo.git")},
    )
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    collection = "ssh_git_user_collection"
    _VectorDb.collections[collection] = [
        {"content": "git user index", "metadata": {"source_path": "doc.md"}},
    ]
    metadata_path = published_index_metadata_path(key)
    metadata_path.parent.mkdir(parents=True, exist_ok=True)
    metadata_path.write_text(
        json.dumps(
            {
                "settings": key.indexing_settings.to_metadata(),
                "status": "complete",
                "collection": collection,
                "indexed_count": 1,
                "source_signature": "test-source-signature",
            },
        ),
        encoding="utf-8",
    )
    knowledge_registry.mark_published_index_refresh_succeeded(key)
    changed_config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": KnowledgeGitConfig(repo_url="ssh://deploy@example.com/org/repo.git")},
    )
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}

    lookup = get_published_index("docs", config=changed_config, runtime_paths=runtime_paths)
    _resolution = resolve_agent_knowledge_access(
        "helper",
        changed_config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert lookup.index is None
    assert lookup.availability is KnowledgeAvailability.CONFIG_MISMATCH
    assert knowledge is None
    assert unavailable == {"docs": KnowledgeAvailability.CONFIG_MISMATCH}
    scheduler.schedule_refresh.assert_called_once()


def test_metadata_state_alone_serves_published_index(tmp_path: Path) -> None:
    """The simplified metadata model keeps active state in the metadata file."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("metadata index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    collection = "metadata_collection"
    _VectorDb.collections[collection] = [
        {"content": "metadata index", "metadata": {"source_path": "doc.md"}},
    ]
    metadata_path = published_index_metadata_path(key)
    metadata_path.parent.mkdir(parents=True, exist_ok=True)
    metadata_path.write_text(
        json.dumps(
            {
                "settings": key.indexing_settings.to_metadata(),
                "status": "complete",
                "collection": collection,
                "indexed_count": 1,
                "source_signature": "test-source-signature",
            },
        ),
        encoding="utf-8",
    )
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert _refresh_state_for_key(key) == "none"
    assert lookup.index is not None
    assert lookup.availability is KnowledgeAvailability.READY


def test_indexing_settings_key_uses_named_settings(tmp_path: Path) -> None:
    """Compatibility helpers must use explicit indexing setting names."""
    docs_path = tmp_path / "docs"
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": KnowledgeGitConfig(repo_url="https://example.com/org/repo.git")},
    )
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)

    assert key.indexing_settings.base_id == "docs"
    assert key.indexing_settings.chunk_size == "5000"
    assert key.indexing_settings.chunk_overlap == "0"
    assert key.indexing_settings.repo_identity == credential_free_url_identity("https://example.com/org/repo.git")
    assert IndexingSettings.from_metadata(key.indexing_settings.to_metadata()) == key.indexing_settings
    changed_repo_identity = replace(key.indexing_settings, repo_identity="https://example.com/other/repo.git")
    assert not knowledge_registry.published_index_settings_compatible(key.indexing_settings, changed_repo_identity)


def test_legacy_empty_optional_filter_metadata_remains_compatible() -> None:
    """Legacy empty semantic filter keys normalize once when metadata is parsed."""
    current = _test_indexing_settings()
    legacy_metadata = current.to_metadata()
    legacy_metadata["include_patterns"] = ""
    del legacy_metadata["exclude_patterns"]
    legacy_metadata["extra_extensions"] = ""
    legacy = IndexingSettings.from_metadata(legacy_metadata)

    assert legacy is not None
    assert legacy == current
    assert knowledge_registry.published_index_settings_compatible(legacy, current)
    assert not knowledge_registry.published_index_settings_compatible(
        legacy,
        replace(current, extra_extensions="('.pdf',)"),
    )


def test_knowledge_file_indexing_parallelism_default_is_conservative() -> None:
    """One refresh subprocess should not fan out into dozens of concurrent file embeds by default."""
    assert knowledge_manager_module._max_concurrent_knowledge_file_indexes() == 4


def test_knowledge_file_indexing_parallelism_reads_env(monkeypatch: pytest.MonkeyPatch) -> None:
    """Large corpora can raise file-level indexing concurrency explicitly."""
    monkeypatch.setenv("MINDROOM_KNOWLEDGE_FILE_INDEX_CONCURRENCY", "16")

    assert knowledge_manager_module._max_concurrent_knowledge_file_indexes() == 16


def test_knowledge_file_indexing_parallelism_is_validated_at_manager_startup(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A bad operator override should fail when the manager is built, before refresh work starts."""
    monkeypatch.setenv("MINDROOM_KNOWLEDGE_FILE_INDEX_CONCURRENCY", "bad")
    config = _config(tmp_path, bases={"docs": tmp_path / "docs"}, agent_bases=["docs"])

    with pytest.raises(ValueError, match="MINDROOM_KNOWLEDGE_FILE_INDEX_CONCURRENCY"):
        KnowledgeManager("docs", config=config, runtime_paths=runtime_paths_for(config))


@pytest.mark.parametrize("raw_value", ["bad", "0", "129"])
def test_knowledge_file_indexing_parallelism_rejects_invalid_env(
    raw_value: str,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Invalid file-level indexing concurrency should fail loudly."""
    monkeypatch.setenv("MINDROOM_KNOWLEDGE_FILE_INDEX_CONCURRENCY", raw_value)

    with pytest.raises(ValueError, match="MINDROOM_KNOWLEDGE_FILE_INDEX_CONCURRENCY"):
        knowledge_manager_module._max_concurrent_knowledge_file_indexes()


def test_indexing_settings_filter_keys_are_order_insensitive(tmp_path: Path) -> None:
    """Reordered filters should not change indexing compatibility settings."""
    docs_path = tmp_path / "docs"
    git_config = KnowledgeGitConfig(
        repo_url="https://example.com/org/repo.git",
        include_patterns=["z/*.md", "a/*.md"],
        exclude_patterns=["drafts/*", "archive/*"],
    )
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    config.knowledge_bases["docs"].include_extensions = [".py", ".md"]
    config.knowledge_bases["docs"].exclude_extensions = [".png", ".jpg"]
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)

    reordered_config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={
            "docs": KnowledgeGitConfig(
                repo_url="https://example.com/org/repo.git",
                include_patterns=["a/*.md", "z/*.md"],
                exclude_patterns=["archive/*", "drafts/*"],
            ),
        },
    )
    reordered_config.knowledge_bases["docs"].include_extensions = [".md", ".py"]
    reordered_config.knowledge_bases["docs"].exclude_extensions = [".jpg", ".png"]
    reordered_key = resolve_published_index_key("docs", config=reordered_config, runtime_paths=runtime_paths)

    assert reordered_key.indexing_settings == key.indexing_settings


def test_file_mode_indexing_settings_ignore_semantic_only_settings(tmp_path: Path) -> None:
    """File-only metadata compatibility should not depend on semantic scan settings."""
    docs_path = tmp_path / "docs"
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        modes={"docs": "files"},
    )
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)

    assert key.indexing_settings.embedder_provider == ""
    assert key.indexing_settings.embedder_model == ""
    assert key.indexing_settings.embedder_host == ""
    assert key.indexing_settings.embedder_dimensions == ""
    assert key.indexing_settings.chunk_size == ""
    assert key.indexing_settings.chunk_overlap == ""
    assert key.indexing_settings.include_extensions == ""
    assert key.indexing_settings.exclude_extensions == ""

    config.knowledge_bases["docs"].include_extensions = [".md"]
    config.knowledge_bases["docs"].exclude_extensions = [".png"]
    changed_key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)

    assert changed_key.indexing_settings == key.indexing_settings


@pytest.mark.asyncio
async def test_git_ready_index_schedules_refresh_after_poll_interval(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Stale Git access can schedule refresh without scanning the local checkout."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("git index", encoding="utf-8")
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", poll_interval_seconds=5)
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)

    async def _sync_success(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, GitSyncResult(head="rev-a", updated=False), "doc.md")

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_success)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    metadata_path = published_index_metadata_path(key)
    payload = json.loads(metadata_path.read_text(encoding="utf-8"))
    payload["last_published_at"] = "2000-01-01T00:00:00+00:00"
    payload["last_refresh_at"] = "2000-01-01T00:00:00+00:00"
    metadata_path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
    knowledge_registry._published_indexes.clear()
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}

    def _unexpected_signature(*_args: object, **_kwargs: object) -> str:
        msg = "git ready access should not scan the local corpus"
        raise AssertionError(msg)

    monkeypatch.setattr("mindroom.knowledge.manager._knowledge_source_signature", _unexpected_signature)
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert knowledge is not None
    assert [document.content for document in knowledge.search("git", max_results=5)] == ["git index"]
    assert unavailable == {"docs": KnowledgeAvailability.STALE}
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_private_git_schedule_refresh_on_access_honors_poll_interval(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Requester-local Git knowledge should not poll before its configured interval has elapsed."""
    runtime_paths = test_runtime_paths(tmp_path)
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", poll_interval_seconds=60)
    config = bind_runtime_paths(
        Config(
            agents={
                "helper": AgentConfig(
                    display_name="Helper",
                    private=AgentPrivateConfig(
                        per="user",
                        root="mind_data",
                        knowledge=AgentPrivateKnowledgeConfig(path="knowledge", git=git_config),
                    ),
                ),
            },
            models={},
        ),
        runtime_paths,
    )
    base_id = config.resolve_entity("helper").private_knowledge_base_id
    assert base_id is not None
    identity = _identity("@alice:localhost")
    key = resolve_published_index_key(
        base_id,
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=identity,
        create=True,
    )
    knowledge_path = Path(key.knowledge_path)
    knowledge_path.mkdir(parents=True, exist_ok=True)
    (knowledge_path / "note.md").write_text("alice private git note", encoding="utf-8")

    async def _sync_success(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, GitSyncResult(head="rev-a", updated=False), "note.md")

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_success)
    await refresh_knowledge_binding(base_id, config=config, runtime_paths=runtime_paths, execution_identity=identity)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        execution_identity=identity,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert knowledge is not None
    assert unavailable == {}
    scheduler.schedule_refresh.assert_not_called()

    metadata_path = published_index_metadata_path(key)
    payload = json.loads(metadata_path.read_text(encoding="utf-8"))
    payload["last_published_at"] = "2000-01-01T00:00:00+00:00"
    payload["last_refresh_at"] = "2000-01-01T00:00:00+00:00"
    metadata_path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
    knowledge_registry._published_indexes.clear()
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        execution_identity=identity,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    stale_knowledge = _resolution.knowledge

    assert stale_knowledge is not None
    assert unavailable == {base_id: KnowledgeAvailability.STALE}
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_private_git_updated_refresh_preserves_execution_identity(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Private Git updates must mark stale and rebuild through the requester binding."""
    runtime_paths = test_runtime_paths(tmp_path)
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")
    config = bind_runtime_paths(
        Config(
            agents={
                "helper": AgentConfig(
                    display_name="Helper",
                    private=AgentPrivateConfig(
                        per="user",
                        root="mind_data",
                        knowledge=AgentPrivateKnowledgeConfig(path="knowledge", git=git_config),
                    ),
                ),
            },
            models={},
        ),
        runtime_paths,
    )
    base_id = config.resolve_entity("helper").private_knowledge_base_id
    assert base_id is not None
    identity = _identity("@alice:localhost")
    key = resolve_published_index_key(
        base_id,
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=identity,
        create=True,
    )
    knowledge_path = Path(key.knowledge_path)
    knowledge_path.mkdir(parents=True, exist_ok=True)
    (knowledge_path / "note.md").write_text("alice private git updated", encoding="utf-8")

    async def _sync_updated(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, GitSyncResult(head="rev-private", updated=True), "note.md")

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_updated)

    result = await refresh_knowledge_binding(
        base_id,
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=identity,
    )
    lookup = get_published_index(base_id, config=config, runtime_paths=runtime_paths, execution_identity=identity)

    assert result.index_published is True
    assert lookup.index is not None
    assert lookup.availability is KnowledgeAvailability.READY
    assert [document.content for document in lookup.index.knowledge.search("updated", max_results=5)] == [
        "alice private git updated",
    ]


@pytest.mark.asyncio
async def test_existing_published_index_is_used_while_refresh_runs(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Slow refresh builds a candidate while readers continue using the last-good index."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("old index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    (docs_path / "doc.md").write_text("new index", encoding="utf-8")

    started = asyncio.Event()
    release = asyncio.Event()
    original_index_file_locked = KnowledgeManager._index_file_locked

    async def _block_candidate(
        self: KnowledgeManager,
        resolved_path: Path,
        *,
        upsert: bool,
        knowledge: object,
        indexed_signatures: dict[str, tuple[int, int, str]],
    ) -> bool:
        if knowledge is not self._knowledge and not started.is_set():
            started.set()
            await release.wait()
        return await original_index_file_locked(
            self,
            resolved_path,
            upsert=upsert,
            knowledge=knowledge,
            indexed_signatures=indexed_signatures,
        )

    monkeypatch.setattr(KnowledgeManager, "_index_file_locked", _block_candidate)
    refresh_task = asyncio.create_task(refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths))
    await started.wait()

    knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
    assert knowledge is not None
    assert [document.content for document in knowledge.search("index", max_results=5)] == ["old index"]

    release.set()
    await refresh_task
    knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
    assert knowledge is not None
    assert [document.content for document in knowledge.search("index", max_results=5)] == ["new index"]


@pytest.mark.asyncio
async def test_cancelled_refresh_keeps_unpublished_candidate_for_resume(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Cancelling a candidate refresh keeps its progress without ever publishing it."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("cancel stable", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    existing_collections = set(_VectorDb.collections)
    doc.write_text("cancel candidate", encoding="utf-8")
    candidate_started = asyncio.Event()
    original_index_file_locked = KnowledgeManager._index_file_locked

    async def _block_candidate(
        self: KnowledgeManager,
        resolved_path: Path,
        *,
        upsert: bool,
        knowledge: object,
        indexed_signatures: dict[str, tuple[int, int, str]],
    ) -> bool:
        if knowledge is not self._knowledge:
            candidate_started.set()
            await asyncio.Event().wait()
        return await original_index_file_locked(
            self,
            resolved_path,
            upsert=upsert,
            knowledge=knowledge,
            indexed_signatures=indexed_signatures,
        )

    monkeypatch.setattr(KnowledgeManager, "_index_file_locked", _block_candidate)
    refresh_task = asyncio.create_task(refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths))
    await candidate_started.wait()
    cancelled_candidate_collections = set(_VectorDb.collections) - existing_collections
    assert any("_candidate_" in collection for collection in cancelled_candidate_collections)

    refresh_task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await refresh_task

    # The candidate survives so the next refresh continues it instead of
    # restarting from zero, but it is still private: readers keep last-good.
    assert cancelled_candidate_collections <= set(_VectorDb.collections)
    checkpoint = load_candidate_checkpoint(_base_storage_path(config, runtime_paths))
    assert checkpoint is not None
    assert checkpoint.collection in cancelled_candidate_collections
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.collection not in cancelled_candidate_collections
    knowledge = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
    assert knowledge is not None
    assert [document.content for document in knowledge.search("cancel", max_results=5)] == ["cancel stable"]


@pytest.mark.asyncio
async def test_cancelled_publish_metadata_save_keeps_published_candidate_collection(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Cancelling during READY metadata save must not delete the metadata's candidate collection."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("stable metadata", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    cached_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert cached_lookup.index is not None
    assert [document.content for document in cached_lookup.index.knowledge.search("metadata", max_results=5)] == [
        "stable metadata",
    ]
    doc.write_text("candidate metadata", encoding="utf-8")
    loop = asyncio.get_running_loop()
    metadata_saved = asyncio.Event()
    release_metadata_save = Event()

    def _block_after_candidate_metadata_save(metadata_path: Path, state: PublishedIndexState) -> None:
        save_published_index_state(metadata_path, state)
        if state.status == "complete" and "_candidate_" in str(state.collection):
            loop.call_soon_threadsafe(metadata_saved.set)
            assert release_metadata_save.wait(timeout=5)

    monkeypatch.setattr(
        "mindroom.knowledge.manager.save_published_index_state",
        _block_after_candidate_metadata_save,
    )

    refresh_task = asyncio.create_task(refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths))
    await metadata_saved.wait()
    refresh_task.cancel()
    await asyncio.sleep(0)
    release_metadata_save.set()
    with pytest.raises(asyncio.CancelledError):
        await refresh_task

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.collection is not None
    assert "_candidate_" in state.collection
    assert state.collection in _VectorDb.collections
    assert knowledge_registry.published_index_refresh_state(state) == "none"
    assert state.refresh_job == "idle"

    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("metadata", max_results=5)] == [
        "candidate metadata",
    ]


@pytest.mark.asyncio
async def test_publish_metadata_save_finishes_before_repeated_cancellation_escapes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Repeated cancellation must not interrupt the metadata save drain."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths)
    candidate_vector_db = build_vector_db(manager._collections, candidate_collection_name(manager._collections))
    loop = asyncio.get_running_loop()
    save_started = asyncio.Event()
    release_save = Event()

    def _blocked_save(*_args: object, **_kwargs: object) -> None:
        loop.call_soon_threadsafe(save_started.set)
        assert release_save.wait(timeout=5)

    monkeypatch.setattr("mindroom.knowledge.manager.save_published_index_state", _blocked_save)
    save = asyncio.create_task(
        manager._save_candidate_publish_metadata(
            candidate_vector_db=candidate_vector_db,
            indexed_count=0,
            source_signature="source-signature",
        ),
    )
    await save_started.wait()
    try:
        save.cancel()
        await asyncio.sleep(0)
        save.cancel()
        await asyncio.sleep(0)
        assert not save.done(), "repeated cancellation escaped before the metadata save finished"
    finally:
        release_save.set()

    assert await save is True


@pytest.mark.asyncio
async def test_cancelled_publish_metadata_save_surfaces_a_failed_write(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A cancelled-but-failed metadata save must not report a publication."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths)
    candidate_vector_db = build_vector_db(manager._collections, candidate_collection_name(manager._collections))
    loop = asyncio.get_running_loop()
    save_started = asyncio.Event()
    release_save = Event()

    def _failed_save(*_args: object, **_kwargs: object) -> None:
        loop.call_soon_threadsafe(save_started.set)
        assert release_save.wait(timeout=5)
        msg = "publish metadata write failed"
        raise RuntimeError(msg)

    monkeypatch.setattr("mindroom.knowledge.manager.save_published_index_state", _failed_save)
    save = asyncio.create_task(
        manager._save_candidate_publish_metadata(
            candidate_vector_db=candidate_vector_db,
            indexed_count=0,
            source_signature="source-signature",
        ),
    )
    await save_started.wait()
    save.cancel()
    await asyncio.sleep(0)
    release_save.set()

    with pytest.raises(RuntimeError, match="publish metadata write failed"):
        await save


@pytest.mark.asyncio
async def test_publishing_states_every_field_of_the_state_file(tmp_path: Path) -> None:
    """Publishing writes a whole state instead of dropping the fields it does not own.

    The publish path used to hand a writer only the seven publication fields,
    so the six the refresh job owns reverted to defaults nobody chose: the
    timestamps went missing entirely and the failure streak silently reset,
    and only the caller that marks the refresh succeeded put them back. The
    writer now takes a whole state, so publication has to say what it means.
    """
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    metadata_path = published_index_metadata_path(key)
    earlier = "2026-01-02T03:04:05+00:00"
    save_published_index_state(
        metadata_path,
        PublishedIndexState(
            settings=key.indexing_settings,
            status="complete",
            collection="docs_previous",
            last_published_at=earlier,
            published_revision="cafebabe",
            indexed_count=1,
            source_signature="previous-signature",
            refresh_job="running",
            reason="refreshing",
            last_error="boom",
            updated_at=earlier,
            last_refresh_at=earlier,
            consecutive_refresh_failures=3,
        ),
    )
    candidate_vector_db = build_vector_db(manager._collections, candidate_collection_name(manager._collections))

    assert (
        await manager._save_candidate_publish_metadata(
            candidate_vector_db=candidate_vector_db,
            indexed_count=4,
            source_signature="new-signature",
        )
        is False
    )

    payload = json.loads(metadata_path.read_text(encoding="utf-8"))
    assert {"refresh_job", "consecutive_refresh_failures", "updated_at", "last_refresh_at"} <= set(payload)
    state = load_published_index_state(metadata_path)
    assert state is not None
    assert (state.collection, state.indexed_count, state.source_signature) == (
        candidate_vector_db.collection_name,
        4,
        "new-signature",
    )
    # Publication resolves the refresh job it belongs to, and stamps the write.
    assert (state.refresh_job, state.reason, state.last_error) == ("idle", None, None)
    assert state.consecutive_refresh_failures == 0
    assert state.updated_at is not None
    assert state.last_refresh_at is not None
    assert state.updated_at > earlier
    assert state.last_refresh_at > earlier


@pytest.mark.asyncio
async def test_refresh_never_publishes_while_source_keeps_changing(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A source that changes on every pass keeps last-good and its candidate work.

    Published metadata stays bound to the exact corpus that was indexed, and a
    source mutating faster than the refresh converges must not cost the
    candidate the vectors it already built.
    """
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("stable index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("candidate index", encoding="utf-8")
    original_reindex_files_locked = KnowledgeManager._reindex_files_locked
    late_additions = 0

    async def _mutate_after_candidate_index(
        self: KnowledgeManager,
        files: list[Path],
        **kwargs: object,
    ) -> int:
        nonlocal late_additions
        indexed_count = await original_reindex_files_locked(self, files, **kwargs)
        late_additions += 1
        (docs_path / f"late{late_additions}.md").write_text("late addition", encoding="utf-8")
        return indexed_count

    monkeypatch.setattr(KnowledgeManager, "_reindex_files_locked", _mutate_after_candidate_index)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is False
    assert result.availability is KnowledgeAvailability.REFRESH_FAILED
    assert result.last_error == (
        "Knowledge source kept changing during refresh; candidate progress was kept for the next refresh"
    )
    assert lookup.index is not None
    assert lookup.availability is KnowledgeAvailability.REFRESH_FAILED
    assert [document.content for document in lookup.index.knowledge.search("index", max_results=5)] == [
        "stable index",
    ]
    checkpoint = load_candidate_checkpoint(_base_storage_path(config, runtime_paths))
    assert checkpoint is not None
    assert "doc.md" in checkpoint.completed


@pytest.mark.asyncio
async def test_refresh_reconciles_one_source_change_and_publishes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A single mid-refresh change is reconciled in the next pass instead of discarded."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("stable index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    original_reindex_files_locked = KnowledgeManager._reindex_files_locked
    mutated = False

    async def _mutate_once(self: KnowledgeManager, files: list[Path], **kwargs: object) -> int:
        nonlocal mutated
        indexed_count = await original_reindex_files_locked(self, files, **kwargs)
        if not mutated:
            mutated = True
            (docs_path / "late.md").write_text("late addition", encoding="utf-8")
        return indexed_count

    monkeypatch.setattr(KnowledgeManager, "_reindex_files_locked", _mutate_once)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is True
    assert result.availability is KnowledgeAvailability.READY
    assert lookup.index is not None
    assert sorted(
        document.meta_data["source_path"] for document in lookup.index.knowledge.search("index", max_results=5)
    ) == ["doc.md", "late.md"]
    # Publication retires the candidate checkpoint.
    assert load_candidate_checkpoint(_base_storage_path(config, runtime_paths)) is None


@pytest.mark.asyncio
async def test_same_physical_binding_refreshes_are_serialized_across_config_changes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Refresh writes are serialized by physical storage target, not settings-sensitive index key."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    changed_config = config.model_copy(deep=True)
    changed_config.knowledge_bases["docs"].chunk_size = 1024
    runtime_paths = runtime_paths_for(config)
    first_entered = asyncio.Event()
    release_first = asyncio.Event()
    second_entered = asyncio.Event()
    active_refreshes = 0
    max_active_refreshes = 0
    call_count = 0

    async def _blocked_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        _ = force_reindex
        _ = self
        nonlocal active_refreshes, max_active_refreshes, call_count
        active_refreshes += 1
        max_active_refreshes = max(max_active_refreshes, active_refreshes)
        call_count += 1
        try:
            if call_count == 1:
                first_entered.set()
                await release_first.wait()
            else:
                second_entered.set()
            return RefreshOutcome(indexed_count=0, published=False, error=None)
        finally:
            active_refreshes -= 1

    monkeypatch.setattr(KnowledgeManager, "reindex_all", _blocked_reindex)

    first_task = asyncio.create_task(refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths))
    await first_entered.wait()
    second_task = asyncio.create_task(
        refresh_knowledge_binding("docs", config=changed_config, runtime_paths=runtime_paths),
    )
    await asyncio.sleep(0)

    assert not second_entered.is_set()
    assert max_active_refreshes == 1

    release_first.set()
    await asyncio.gather(first_task, second_task)

    assert second_entered.is_set()
    assert max_active_refreshes == 1


@pytest.mark.asyncio
async def test_shared_source_mutation_waits_for_duplicate_base_refresh(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Duplicate bases sharing one source folder must serialize refreshes and source mutations."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("index", encoding="utf-8")
    config = _config(tmp_path, bases={"alpha": docs_path, "beta": docs_path}, agent_bases=["alpha", "beta"])
    runtime_paths = runtime_paths_for(config)
    refresh_entered = asyncio.Event()
    release_refresh = asyncio.Event()
    mutation_entered = asyncio.Event()

    async def _blocked_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        _ = force_reindex
        _ = self
        refresh_entered.set()
        await release_refresh.wait()
        return RefreshOutcome(indexed_count=0, published=False, error=None)

    async def _mutate_shared_source() -> None:
        async with knowledge_binding_mutation_lock("beta", config=config, runtime_paths=runtime_paths):
            mutation_entered.set()
            doc.write_text("mutated", encoding="utf-8")
            knowledge_registry._mark_knowledge_source_changed(
                "beta",
                config=config,
                runtime_paths=runtime_paths,
            )

    monkeypatch.setattr(KnowledgeManager, "reindex_all", _blocked_reindex)

    refresh_task = asyncio.create_task(refresh_knowledge_binding("alpha", config=config, runtime_paths=runtime_paths))
    await refresh_entered.wait()
    mutation_task = asyncio.create_task(_mutate_shared_source())
    await asyncio.sleep(0)

    assert not mutation_entered.is_set()

    release_refresh.set()
    await asyncio.gather(refresh_task, mutation_task)

    assert mutation_entered.is_set()
    assert doc.read_text(encoding="utf-8") == "mutated"


@pytest.mark.asyncio
async def test_refresh_uses_cross_process_source_lock(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Direct refreshes should participate in the same source-root file lock as subprocess refreshes."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    expected_source_root = knowledge_registry.source_root_for_published_index_key(key)
    locked_roots: list[knowledge_registry.KnowledgeSourceRoot] = []

    @asynccontextmanager
    async def _record_file_lock(source_root: knowledge_registry.KnowledgeSourceRoot) -> AsyncIterator[None]:
        locked_roots.append(source_root)
        yield

    monkeypatch.setattr(knowledge_refresh_locks, "_acquire_refresh_file_lock", _record_file_lock)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert locked_roots == [expected_source_root]


@pytest.mark.asyncio
async def test_mutation_lock_uses_cross_process_source_lock(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Source mutations should participate in the same source-root file lock as refreshes."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    refresh_target = knowledge_registry.resolve_refresh_target("docs", config=config, runtime_paths=runtime_paths)
    expected_source_root = knowledge_registry.source_root_for_refresh_target(refresh_target)
    locked_roots: list[knowledge_registry.KnowledgeSourceRoot] = []

    @asynccontextmanager
    async def _record_file_lock(source_root: knowledge_registry.KnowledgeSourceRoot) -> AsyncIterator[None]:
        locked_roots.append(source_root)
        yield

    monkeypatch.setattr(knowledge_refresh_locks, "_acquire_refresh_file_lock", _record_file_lock)

    async with knowledge_binding_mutation_lock("docs", config=config, runtime_paths=runtime_paths):
        pass

    assert locked_roots == [expected_source_root]


@pytest.mark.asyncio
async def test_cancelled_async_file_lock_waiter_closes_unacquired_handle(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A cancelled file-lock waiter must not leak a handle that can later acquire the lock."""
    lock_path = Path("/storage/docs.lock")
    opened = asyncio.Event()
    closed: list[FakeLockFile] = []
    released: list[int] = []

    class FakeLockFile:
        def fileno(self) -> int:
            return 123

        def close(self) -> None:
            closed.append(self)

    handle = FakeLockFile()

    def _open(_lock_path: Path) -> FakeLockFile:
        opened.set()
        return handle

    def _flock(file_descriptor: int, flags: int) -> None:
        assert file_descriptor == 123
        if flags & file_locks.fcntl.LOCK_EX:
            raise BlockingIOError
        released.append(flags)

    async def _wait_for_file_lock() -> None:
        async with file_locks.async_exclusive_file_lock(lock_path, poll_seconds=0.001):
            pytest.fail("lock waiter unexpectedly acquired the file lock")

    monkeypatch.setattr(file_locks, "_open_lock_file", _open)
    monkeypatch.setattr(file_locks.fcntl, "flock", _flock)

    waiter = asyncio.create_task(_wait_for_file_lock())
    await opened.wait()
    await asyncio.sleep(0)

    waiter.cancel()
    with pytest.raises(asyncio.CancelledError):
        await waiter

    assert closed == [handle]
    assert released == []


@pytest.mark.asyncio
async def test_refresh_generations_keep_latest_index_without_protecting_old_handles(tmp_path: Path) -> None:
    """Old read handles are best effort; refresh cleanup only guarantees the next active index."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("generation one", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    first_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert first_lookup.index is not None
    first_collection = first_lookup.index.knowledge.vector_db.collection_name

    for generation in range(2, 7):
        doc.write_text(f"generation {generation}", encoding="utf-8")
        await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert first_collection not in _VectorDb.collections
    latest = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert latest.index is not None
    assert [document.content for document in latest.index.knowledge.search("generation", max_results=5)] == [
        "generation 6",
    ]


@pytest.mark.asyncio
async def test_get_published_index_reopens_when_persisted_collection_changes(tmp_path: Path) -> None:
    """A child-process publish must invalidate stale parent process read handles."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("parent cached", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    cached_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert cached_lookup.index is not None
    assert cached_lookup.state is not None
    cached_index = cached_lookup.index

    child_collection = "external_child_collection"
    with _VectorDb.lock:
        _VectorDb.collections[child_collection] = [
            {"content": "child published", "metadata": {"path": "doc.md"}},
        ]
    child_state = replace(
        cached_lookup.state,
        collection=child_collection,
        last_published_at="2026-04-27T00:00:00+00:00",
        source_signature="child-source-signature",
    )
    knowledge_registry.save_published_index_state(published_index_metadata_path(cached_lookup.key), child_state)

    refreshed_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert refreshed_lookup.index is not None
    assert refreshed_lookup.index is not cached_index
    assert refreshed_lookup.index.state == child_state
    assert [document.content for document in refreshed_lookup.index.knowledge.search("child", max_results=5)] == [
        "child published",
    ]


@pytest.mark.asyncio
async def test_publish_invalidates_cached_indexes_for_same_physical_binding(tmp_path: Path) -> None:
    """A config transition and revert must not resurrect an older cached handle for the same path."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("config a", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    cached_a = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert cached_a.index is not None
    assert [document.content for document in cached_a.index.knowledge.search("config", max_results=5)] == [
        "config a",
    ]

    changed_config = config.model_copy(deep=True)
    changed_config.knowledge_bases["docs"].chunk_size = 1024
    doc.write_text("config b", encoding="utf-8")
    await refresh_knowledge_binding("docs", config=changed_config, runtime_paths=runtime_paths)
    reverted_lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert reverted_lookup.index is not None
    assert reverted_lookup.availability is KnowledgeAvailability.CONFIG_MISMATCH
    assert [document.content for document in reverted_lookup.index.knowledge.search("config", max_results=5)] == [
        "config b",
    ]


@pytest.mark.asyncio
async def test_successful_refreshes_keep_only_published_index(tmp_path: Path) -> None:
    """Repeated publishes keep the published index and clean older generations best effort."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("generation 0", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    for generation in range(6):
        doc.write_text(f"generation {generation}", encoding="utf-8")
        await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.collection in _VectorDb.collections
    assert len(_VectorDb.collections) == 1
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("generation", max_results=5)] == [
        "generation 5",
    ]


@pytest.mark.asyncio
async def test_refresh_rebuilds_malformed_metadata_without_serving_old_collection(tmp_path: Path) -> None:
    """Malformed metadata forces a fresh publish without serving the old collection."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("stale list old", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths)
    default_collection = manager._collections.default_collection
    _VectorDb.collections[default_collection] = [
        {"content": "stale list old", "metadata": {"source_path": "doc.md"}},
    ]
    metadata_path = published_index_metadata_path(key)
    metadata_path.parent.mkdir(parents=True, exist_ok=True)
    metadata_path.write_text(json.dumps(["malformed"]), encoding="utf-8")
    doc.write_text("stale list new", encoding="utf-8")

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert default_collection not in _VectorDb.collections
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("stale", max_results=5)] == [
        "stale list new",
    ]


@pytest.mark.asyncio
async def test_superseded_collection_listing_failure_is_best_effort(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Cleanup listing failures must not turn an already-committed publish into a refresh failure."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("cleanup old", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("cleanup new", encoding="utf-8")

    def _raise_list_collections(self: _Client) -> list[str]:
        _ = self
        msg = "list failed"
        raise RuntimeError(msg)

    monkeypatch.setattr(_Client, "list_collections", _raise_list_collections)
    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is True
    assert result.availability is KnowledgeAvailability.READY
    assert lookup.index is not None
    assert lookup.availability is KnowledgeAvailability.READY
    assert [document.content for document in lookup.index.knowledge.search("cleanup", max_results=5)] == [
        "cleanup new",
    ]


@pytest.mark.asyncio
async def test_failed_refresh_preserves_last_good_index(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    """A failed candidate build marks stale availability but keeps serving the old collection."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("stable index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    (docs_path / "doc.md").write_text("broken refresh", encoding="utf-8")
    original_index_file_locked = KnowledgeManager._index_file_locked

    async def _fail_candidate(
        self: KnowledgeManager,
        resolved_path: Path,
        *,
        upsert: bool,
        knowledge: object,
        indexed_signatures: dict[str, tuple[int, int, str]],
    ) -> bool:
        if knowledge is not self._knowledge:
            msg = "candidate failed"
            raise RuntimeError(msg)
        return await original_index_file_locked(
            self,
            resolved_path,
            upsert=upsert,
            knowledge=knowledge,
            indexed_signatures=indexed_signatures,
        )

    monkeypatch.setattr(KnowledgeManager, "_index_file_locked", _fail_candidate)
    with pytest.raises(RuntimeError, match="candidate failed"):
        await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert unavailable == {"docs": KnowledgeAvailability.REFRESH_FAILED}
    assert knowledge is not None
    assert [document.content for document in knowledge.search("index", max_results=5)] == ["stable index"]


@pytest.mark.asyncio
async def test_metadata_save_failure_after_candidate_index_keeps_serving_last_good(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A candidate whose metadata did not commit must not replace the published read handle."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("stable metadata index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("uncommitted candidate index", encoding="utf-8")

    def _fail_candidate_metadata_save(metadata_path: Path, state: PublishedIndexState) -> None:
        if state.status == "complete" and "_candidate_" in str(state.collection):
            msg = "metadata commit failed"
            raise OSError(msg)
        save_published_index_state(metadata_path, state)

    monkeypatch.setattr("mindroom.knowledge.manager.save_published_index_state", _fail_candidate_metadata_save)
    with pytest.raises(OSError, match="metadata commit failed"):
        await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert unavailable == {"docs": KnowledgeAvailability.REFRESH_FAILED}
    assert knowledge is not None
    assert [document.content for document in knowledge.search("index", max_results=5)] == [
        "stable metadata index",
    ]


@pytest.mark.asyncio
async def test_partial_refresh_after_cached_index_updates_failed_availability(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A partial refresh must not leave the process-local READY index hiding failure metadata."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "good.md").write_text("last good", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    assert resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge is not None
    (docs_path / "bad.md").write_text("bad candidate", encoding="utf-8")
    original_index_file_locked = KnowledgeManager._index_file_locked

    async def _skip_bad_file(
        self: KnowledgeManager,
        resolved_path: Path,
        *,
        upsert: bool,
        knowledge: object,
        indexed_signatures: dict[str, tuple[int, int, str]],
    ) -> bool:
        if resolved_path.name == "bad.md":
            return False
        return await original_index_file_locked(
            self,
            resolved_path,
            upsert=upsert,
            knowledge=knowledge,
            indexed_signatures=indexed_signatures,
        )

    monkeypatch.setattr(KnowledgeManager, "_index_file_locked", _skip_bad_file)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert result.index_published is False
    assert result.availability is KnowledgeAvailability.REFRESH_FAILED
    assert unavailable == {"docs": KnowledgeAvailability.REFRESH_FAILED}
    assert knowledge is not None
    assert [document.content for document in knowledge.search("good", max_results=5)] == ["last good"]


@pytest.mark.asyncio
async def test_embedder_config_mismatch_returns_no_incompatible_index(tmp_path: Path) -> None:
    """An embedder-changing config mismatch should not query old vectors with the new embedder."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("old embedder index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    changed_config = config.model_copy(deep=True)
    changed_config.memory.embedder.config.model = "text-embedding-3-large"
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        changed_config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert knowledge is None
    assert unavailable == {"docs": KnowledgeAvailability.CONFIG_MISMATCH}
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_config_mismatch_refresh_cooldown_is_settings_aware(tmp_path: Path) -> None:
    """A newer config mismatch for the same binding must not be dropped by the request cooldown."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("old index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    changed_config = config.model_copy(deep=True)
    changed_config.knowledge_bases["docs"].chunk_size = 1024
    newer_config = config.model_copy(deep=True)
    newer_config.knowledge_bases["docs"].chunk_size = 2048
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    assert (
        resolve_agent_knowledge_access("helper", changed_config, runtime_paths, refresh_scheduler=scheduler).knowledge
        is not None
    )
    assert (
        resolve_agent_knowledge_access("helper", newer_config, runtime_paths, refresh_scheduler=scheduler).knowledge
        is not None
    )

    assert scheduler.schedule_refresh.call_count == 2
    assert scheduler.schedule_refresh.call_args_list[0].kwargs["config"] is changed_config
    assert scheduler.schedule_refresh.call_args_list[1].kwargs["config"] is newer_config


@pytest.mark.asyncio
async def test_initializing_refresh_cooldown_is_settings_aware(tmp_path: Path) -> None:
    """A cold initial load under old settings must not suppress a newer config's initial load."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    changed_config = config.model_copy(deep=True)
    changed_config.knowledge_bases["docs"].chunk_size = 1024
    newer_config = config.model_copy(deep=True)
    newer_config.knowledge_bases["docs"].chunk_size = 2048
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    assert (
        resolve_agent_knowledge_access("helper", changed_config, runtime_paths, refresh_scheduler=scheduler).knowledge
        is None
    )
    assert (
        resolve_agent_knowledge_access("helper", newer_config, runtime_paths, refresh_scheduler=scheduler).knowledge
        is None
    )

    assert scheduler.schedule_refresh.call_count == 2
    assert scheduler.schedule_refresh.call_args_list[0].kwargs["config"] is changed_config
    assert scheduler.schedule_refresh.call_args_list[1].kwargs["config"] is newer_config


@pytest.mark.asyncio
async def test_cold_failed_refresh_cooldown_is_settings_aware(tmp_path: Path) -> None:
    """A failed cold refresh under old settings must not suppress a newer config's retry."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="cold failure")
    changed_config = config.model_copy(deep=True)
    changed_config.knowledge_bases["docs"].chunk_size = 1024
    newer_config = config.model_copy(deep=True)
    newer_config.knowledge_bases["docs"].chunk_size = 2048
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        changed_config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    _resolution = resolve_agent_knowledge_access(
        "helper",
        newer_config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})

    assert unavailable == {"docs": KnowledgeAvailability.REFRESH_FAILED}
    assert scheduler.schedule_refresh.call_count == 2
    assert scheduler.schedule_refresh.call_args_list[0].kwargs["config"] is changed_config
    assert scheduler.schedule_refresh.call_args_list[1].kwargs["config"] is newer_config


@pytest.mark.asyncio
async def test_refresh_failed_detail_carries_persisted_last_error(tmp_path: Path) -> None:
    """The availability detail exposes the persisted refresh failure cause."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(
        key,
        error="Indexed 0 of 1 managed knowledge files (first error: embedder authentication failed (HTTP 401))",
    )
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    resolution = resolve_agent_knowledge_access("helper", config, runtime_paths, refresh_scheduler=scheduler)

    detail = resolution.unavailable["docs"]
    assert detail.availability is KnowledgeAvailability.REFRESH_FAILED
    assert detail.last_error == (
        "Indexed 0 of 1 managed knowledge files (first error: embedder authentication failed (HTTP 401))"
    )


@pytest.mark.asyncio
async def test_failed_git_refresh_cooldown_is_credentials_service_aware(tmp_path: Path) -> None:
    """Changing Git auth service config should bypass the failed-refresh retry cooldown."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    git_config = KnowledgeGitConfig(
        repo_url="https://example.com/org/private.git",
        credentials_service="old_service",
    )
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="auth failed")
    changed_config = config.model_copy(deep=True)
    changed_git_config = changed_config.knowledge_bases["docs"].git
    assert changed_git_config is not None
    changed_git_config.credentials_service = "new_service"
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    assert (
        resolve_agent_knowledge_access("helper", config, runtime_paths, refresh_scheduler=scheduler).knowledge is None
    )
    assert (
        resolve_agent_knowledge_access("helper", changed_config, runtime_paths, refresh_scheduler=scheduler).knowledge
        is None
    )

    assert scheduler.schedule_refresh.call_count == 2
    assert scheduler.schedule_refresh.call_args_list[0].kwargs["config"] is config
    assert scheduler.schedule_refresh.call_args_list[1].kwargs["config"] is changed_config


@pytest.mark.asyncio
async def test_failed_git_refresh_cooldown_is_embedded_userinfo_aware(tmp_path: Path) -> None:
    """Changing embedded Git URL auth should bypass cooldown without storing the secret."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    git_config = KnowledgeGitConfig(
        repo_url="https://git-user:old-secret@example.com/org/private.git",
    )
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="auth failed")
    changed_config = config.model_copy(deep=True)
    changed_git_config = changed_config.knowledge_bases["docs"].git
    assert changed_git_config is not None
    changed_git_config.repo_url = "https://git-user:new-secret@example.com/org/private.git"
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    assert (
        resolve_agent_knowledge_access("helper", config, runtime_paths, refresh_scheduler=scheduler).knowledge is None
    )
    assert (
        resolve_agent_knowledge_access("helper", changed_config, runtime_paths, refresh_scheduler=scheduler).knowledge
        is None
    )

    assert scheduler.schedule_refresh.call_count == 2
    assert scheduler.schedule_refresh.call_args_list[0].kwargs["config"] is config
    assert scheduler.schedule_refresh.call_args_list[1].kwargs["config"] is changed_config
    cooldown_keys = repr(tuple(knowledge_utils._refresh_scheduled_at))
    assert "old-secret" not in cooldown_keys
    assert "new-secret" not in cooldown_keys


@pytest.mark.asyncio
@pytest.mark.parametrize("availability", [KnowledgeAvailability.STALE, KnowledgeAvailability.REFRESH_FAILED])
async def test_stale_or_failed_index_reports_chunking_config_mismatch_before_cooldown(
    tmp_path: Path,
    availability: KnowledgeAvailability,
) -> None:
    """Stale/failed metadata must not suppress refreshes for newer chunking settings."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("old index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    if availability is KnowledgeAvailability.STALE:
        knowledge_registry.mark_published_index_stale(key, reason="test_stale")
    else:
        knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="previous failure")
    knowledge_registry._published_indexes.clear()
    changed_config = config.model_copy(deep=True)
    changed_config.knowledge_bases["docs"].chunk_size = 1024
    newer_config = config.model_copy(deep=True)
    newer_config.knowledge_bases["docs"].chunk_size = 2048
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        changed_config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    assert _resolution.knowledge is not None
    _resolution = resolve_agent_knowledge_access(
        "helper",
        newer_config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    assert _resolution.knowledge is not None

    assert unavailable == {"docs": KnowledgeAvailability.CONFIG_MISMATCH}
    assert scheduler.schedule_refresh.call_count == 2
    assert scheduler.schedule_refresh.call_args_list[0].kwargs["config"] is changed_config
    assert scheduler.schedule_refresh.call_args_list[1].kwargs["config"] is newer_config


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "mutate",
    [
        lambda config: setattr(config.knowledge_bases["docs"].git, "repo_url", "https://example.com/other/repo.git"),
        lambda config: setattr(config.knowledge_bases["docs"].git, "branch", "release"),
        lambda config: setattr(config.knowledge_bases["docs"].git, "include_patterns", ["other/**"]),
        lambda config: setattr(config.knowledge_bases["docs"].git, "exclude_patterns", ["doc.md"]),
        lambda config: setattr(config.knowledge_bases["docs"].git, "skip_hidden", False),
        lambda config: setattr(config.knowledge_bases["docs"], "include_extensions", [".txt"]),
        lambda config: setattr(config.knowledge_bases["docs"], "exclude_extensions", [".md"]),
    ],
)
async def test_corpus_changing_config_mismatch_returns_no_index(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    mutate: object,
) -> None:
    """Source identity and membership filter changes must not serve old content."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("old corpus index", encoding="utf-8")
    git_config = KnowledgeGitConfig(
        repo_url="https://example.com/org/repo.git",
        include_patterns=["**/*.md"],
        skip_hidden=True,
    )
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)

    async def _sync_success(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, GitSyncResult(head="rev-a", updated=True), "doc.md")

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_success)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    changed_config = config.model_copy(deep=True)
    mutate(changed_config)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        changed_config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert knowledge is None
    assert unavailable == {"docs": KnowledgeAvailability.CONFIG_MISMATCH}
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_skip_hidden_change_on_directory_base_returns_no_index(tmp_path: Path) -> None:
    """Toggling skip_hidden on a directory base changes corpus membership, so old content must not be served."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("old corpus index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    changed_config = config.model_copy(deep=True)
    changed_config.knowledge_bases["docs"].skip_hidden = False
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    resolution = resolve_agent_knowledge_access(
        "helper",
        changed_config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )

    assert resolution.knowledge is None
    assert {base_id: detail.availability for (base_id, detail) in resolution.unavailable.items()} == {
        "docs": KnowledgeAvailability.CONFIG_MISMATCH,
    }
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_failed_refresh_after_config_change_preserves_published_settings(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A failed candidate refresh must not rewrite last-good metadata to the attempted settings."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("stable index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    old_key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    old_state = load_published_index_state(published_index_metadata_path(old_key))
    assert old_state is not None

    changed_config = config.model_copy(deep=True)
    changed_config.knowledge_bases["docs"].chunk_size = 1024

    async def _fail_candidate(
        self: KnowledgeManager,
        resolved_path: Path,
        *,
        upsert: bool,
        knowledge: object,
        indexed_signatures: dict[str, tuple[int, int, str]],
    ) -> bool:
        _ = (self, resolved_path, upsert, knowledge, indexed_signatures)
        msg = "candidate failed"
        raise RuntimeError(msg)

    monkeypatch.setattr(KnowledgeManager, "_index_file_locked", _fail_candidate)
    with pytest.raises(RuntimeError, match="candidate failed"):
        await refresh_knowledge_binding("docs", config=changed_config, runtime_paths=runtime_paths)

    changed_key = resolve_published_index_key("docs", config=changed_config, runtime_paths=runtime_paths)
    preserved_state = load_published_index_state(published_index_metadata_path(changed_key))
    assert preserved_state is not None
    assert preserved_state.settings == old_state.settings
    assert preserved_state.collection == old_state.collection
    assert knowledge_registry.published_index_refresh_state(preserved_state) == "refresh_failed"
    assert preserved_state.last_error == "candidate failed"

    lookup = get_published_index("docs", config=changed_config, runtime_paths=runtime_paths)
    assert lookup.index is not None
    assert lookup.availability is KnowledgeAvailability.CONFIG_MISMATCH


def test_stale_metadata_without_collection_returns_unavailable_index(tmp_path: Path) -> None:
    """Metadata alone must not create or expose an empty ready collection."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    metadata_path = published_index_metadata_path(key)
    metadata_path.parent.mkdir(parents=True, exist_ok=True)
    metadata_path.write_text(
        json.dumps(
            {
                "settings": key.indexing_settings.to_metadata(),
                "status": "complete",
                "collection": "missing_collection",
                "indexed_count": 1,
                "source_signature": "test-source-signature",
            },
        ),
        encoding="utf-8",
    )
    knowledge_registry.mark_published_index_refresh_succeeded(key)

    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert lookup.index is None
    assert lookup.availability is KnowledgeAvailability.REFRESH_FAILED
    assert "missing_collection" not in _VectorDb.collections


def test_lookup_failure_after_binding_resolution_schedules_repair_refresh(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A resolved binding with a broken read handle should still queue a repair refresh."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    metadata_path = published_index_metadata_path(key)
    metadata_path.parent.mkdir(parents=True, exist_ok=True)
    metadata_path.write_text(
        json.dumps(
            {
                "settings": key.indexing_settings.to_metadata(),
                "status": "complete",
                "collection": "broken_collection",
                "indexed_count": 1,
                "source_signature": "test-source-signature",
            },
        ),
        encoding="utf-8",
    )
    knowledge_registry.mark_published_index_refresh_succeeded(key)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}

    def _broken_vector_db(*_args: object, **_kwargs: object) -> object:
        msg = "cannot open collection"
        raise RuntimeError(msg)

    monkeypatch.setattr("mindroom.knowledge.registry._build_published_index_vector_db", _broken_vector_db)
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert knowledge is None
    assert unavailable == {"docs": KnowledgeAvailability.REFRESH_FAILED}
    scheduler.schedule_refresh.assert_called_once()


def test_published_index_handle_open_failure_degrades_and_schedules_repair_refresh(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A broken read handle should not break reply-path knowledge resolution."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    collection = "broken_collection"
    metadata_path = published_index_metadata_path(key)
    metadata_path.parent.mkdir(parents=True, exist_ok=True)
    metadata_path.write_text(
        json.dumps(
            {
                "settings": key.indexing_settings.to_metadata(),
                "status": "complete",
                "collection": collection,
                "indexed_count": 1,
                "source_signature": "test-source-signature",
            },
        ),
        encoding="utf-8",
    )
    _VectorDb.collections[collection] = [
        {
            "content": "last good content",
            "metadata": {"path": "guide.md"},
        },
    ]
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    def _broken_vector_db(*_args: object, **_kwargs: object) -> object:
        msg = "cannot open collection"
        raise RuntimeError(msg)

    monkeypatch.setattr("mindroom.knowledge.registry._build_published_index_vector_db", _broken_vector_db)

    resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )

    assert resolution.knowledge is None
    assert resolution.unavailable == {
        "docs": KnowledgeAvailabilityDetail(
            availability=KnowledgeAvailability.REFRESH_FAILED,
            search_available=False,
        ),
    }
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_first_time_partial_refresh_does_not_publish_ready_index(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A cold refresh with incomplete file indexing must not become a last-good index."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "good.md").write_text("good", encoding="utf-8")
    (docs_path / "bad.md").write_text("bad", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    original_index_file_locked = KnowledgeManager._index_file_locked

    async def _skip_bad_file(
        self: KnowledgeManager,
        resolved_path: Path,
        *,
        upsert: bool,
        knowledge: object,
        indexed_signatures: dict[str, tuple[int, int, str]],
    ) -> bool:
        if resolved_path.name == "bad.md":
            return False
        return await original_index_file_locked(
            self,
            resolved_path,
            upsert=upsert,
            knowledge=knowledge,
            indexed_signatures=indexed_signatures,
        )

    monkeypatch.setattr(KnowledgeManager, "_index_file_locked", _skip_bad_file)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert result.indexed_count == 1
    assert result.index_published is False
    assert state is not None
    assert state.status == "indexing"
    assert state.collection is None
    assert knowledge_registry.published_index_refresh_state(state) == "refresh_failed"
    assert state.last_error == "Indexed 1 of 2 managed knowledge files"
    assert lookup.index is None
    assert lookup.availability is KnowledgeAvailability.REFRESH_FAILED
    # The partial candidate stays private but durable: "good.md" must not be
    # embedded again on the next attempt just because "bad.md" failed.
    checkpoint = load_candidate_checkpoint(_base_storage_path(config, runtime_paths))
    assert checkpoint is not None
    assert "_candidate_" in checkpoint.collection
    assert set(checkpoint.completed) == {"good.md"}
    assert set(checkpoint.failed) == {"bad.md"}
    assert checkpoint.status == "failed"


def _embedder_auth_error() -> AuthenticationError:
    request = httpx.Request("POST", "http://embeddings.local/v1/embeddings")
    response = httpx.Response(401, request=request, json={"error": {"message": "Incorrect API key provided"}})
    return AuthenticationError("Error code: 401", response=response, body=None)


@pytest.mark.asyncio
async def test_partial_refresh_error_includes_first_classified_file_error(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The persisted refresh summary carries the first classified per-file indexing error."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "aaa-broken.md").write_text("cannot embed", encoding="utf-8")
    (docs_path / "good.md").write_text("indexed text", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    class _AuthFailingKnowledge(_Knowledge):
        def insert(
            self,
            *,
            path: str,
            metadata: dict[str, object],
            upsert: bool,
            reader: object | None = None,
        ) -> None:
            if Path(path).name == "aaa-broken.md":
                raise _embedder_auth_error()
            super().insert(path=path, metadata=metadata, upsert=upsert, reader=reader)

    monkeypatch.setattr("mindroom.knowledge.collections.Knowledge", _AuthFailingKnowledge)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths_for(config))

    assert await manager.reindex_all() == RefreshOutcome(
        indexed_count=1,
        published=False,
        error="Indexed 1 of 2 managed knowledge files (first error: embedder authentication failed (HTTP 401))",
    )


@pytest.mark.asyncio
async def test_vectorless_file_does_not_inherit_process_global_embedder_health(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A vectorless file is not blamed for a stale failure from another request."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "broken.md").write_text("cannot embed", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])

    class _SwallowingKnowledge(_Knowledge):
        def insert(
            self,
            *,
            path: str,
            metadata: dict[str, object],
            upsert: bool,
            reader: object | None = None,
        ) -> None:
            # Simulate an unrelated request recording health while this insert
            # returns without vectors.
            del path, metadata, upsert, reader
            embedder_health.capture_embedder_health_recorder().record("embedder authentication failed (HTTP 401)")

    monkeypatch.setattr("mindroom.knowledge.collections.Knowledge", _SwallowingKnowledge)
    embedder_health.capture_embedder_health_recorder().record(None)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths_for(config))
    try:
        outcome = await manager.reindex_all()
    finally:
        embedder_health.capture_embedder_health_recorder().record(None)

    assert outcome == RefreshOutcome(
        indexed_count=0,
        published=False,
        error="Indexed 0 of 1 managed knowledge files",
    )


def test_refresh_failure_counter_increments_and_resets_preserving_last_good(tmp_path: Path) -> None:
    """The failure counter climbs across running transitions, keeps last-good fields, resets on success."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    metadata_path = published_index_metadata_path(key)
    save_published_index_state(
        metadata_path,
        PublishedIndexState(
            settings=key.indexing_settings,
            status="complete",
            collection="docs_live",
            indexed_count=1,
            source_signature="sig",
        ),
    )

    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="boom 1")
    first = load_published_index_state(metadata_path)
    assert first is not None
    assert first.consecutive_refresh_failures == 1
    assert first.status == "complete"
    assert first.collection == "docs_live"

    knowledge_registry.mark_published_index_refresh_running(key)
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="boom 2")
    second = load_published_index_state(metadata_path)
    assert second is not None
    assert second.consecutive_refresh_failures == 2
    assert second.last_error == "boom 2"

    knowledge_registry.mark_published_index_refresh_succeeded(key)
    recovered = load_published_index_state(metadata_path)
    assert recovered is not None
    assert recovered.consecutive_refresh_failures == 0
    assert recovered.last_error is None
    assert recovered.status == "complete"
    assert recovered.collection == "docs_live"


def test_refresh_failure_threshold_logs_error_at_three_and_beyond(tmp_path: Path) -> None:
    """The third consecutive failure and every later one log at ERROR level."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)

    with capture_logs() as logs:
        for attempt in range(1, 5):
            knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error=f"boom {attempt}")

    repeated = [entry for entry in logs if entry["event"] == "knowledge_refresh_failing_repeatedly"]
    assert [entry["consecutive_refresh_failures"] for entry in repeated] == [3, 4]
    assert all(entry["log_level"] == "error" for entry in repeated)


def test_legacy_metadata_without_failure_counter_parses_as_zero(tmp_path: Path) -> None:
    """Metadata written before the counter existed loads as zero and increments from there."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    metadata_path = published_index_metadata_path(key)
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="boom 1")
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="boom 2")
    payload = json.loads(metadata_path.read_text(encoding="utf-8"))
    del payload["consecutive_refresh_failures"]
    metadata_path.write_text(json.dumps(payload), encoding="utf-8")

    legacy = load_published_index_state(metadata_path)
    assert legacy is not None
    assert legacy.consecutive_refresh_failures == 0

    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="boom 3")
    bumped = load_published_index_state(metadata_path)
    assert bumped is not None
    assert bumped.consecutive_refresh_failures == 1


def test_published_state_fingerprint_includes_failure_counter(tmp_path: Path) -> None:
    """States differing only in the failure counter fingerprint differently."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(key, error="boom")
    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None

    bumped = replace(state, consecutive_refresh_failures=state.consecutive_refresh_failures + 1)

    assert knowledge_refresh_runner._published_state_fingerprint(state) != (
        knowledge_refresh_runner._published_state_fingerprint(bumped)
    )


@pytest.mark.asyncio
async def test_cold_refresh_publishes_when_empty_file_produces_no_vectors(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Empty managed files should count as scanned without blocking cold index publication."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "useful.md").write_text("useful vectors", encoding="utf-8")
    (docs_path / "empty.md").write_text("", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    class _SkipEmptyKnowledge(_Knowledge):
        def insert(
            self,
            *,
            path: str,
            metadata: dict[str, object],
            upsert: bool,
            reader: object | None = None,
        ) -> None:
            if Path(path).read_text(encoding="utf-8"):
                super().insert(path=path, metadata=metadata, upsert=upsert, reader=reader)

    monkeypatch.setattr("mindroom.knowledge.collections.Knowledge", _SkipEmptyKnowledge)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert result.indexed_count == 2
    assert result.index_published is True
    assert result.availability is KnowledgeAvailability.READY
    assert state is not None
    assert state.indexed_count == 2
    assert knowledge_registry.published_index_refresh_state(state) == "none"
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("useful", max_results=5)] == [
        "useful vectors",
    ]


@pytest.mark.asyncio
async def test_embedder_changing_partial_refresh_does_not_publish_old_index_under_new_key(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A partial refresh cannot cache old incompatible vectors under a new index key."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("old embedder index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("new embedder candidate", encoding="utf-8")
    changed_config = config.model_copy(deep=True)
    changed_config.memory.embedder.config.model = "text-embedding-3-large"

    async def _partial_candidate(
        self: KnowledgeManager,
        resolved_path: Path,
        *,
        upsert: bool,
        knowledge: object,
        indexed_signatures: dict[str, tuple[int, int, str]],
    ) -> bool:
        _ = (self, resolved_path, upsert, knowledge, indexed_signatures)
        return False

    monkeypatch.setattr(KnowledgeManager, "_index_file_locked", _partial_candidate)

    result = await refresh_knowledge_binding("docs", config=changed_config, runtime_paths=runtime_paths)
    lookup = get_published_index("docs", config=changed_config, runtime_paths=runtime_paths)

    assert result.indexed_count == 0
    assert result.index_published is False
    assert lookup.index is None
    assert lookup.availability is KnowledgeAvailability.CONFIG_MISMATCH


@pytest.mark.asyncio
async def test_cold_refresh_exception_surfaces_failed_availability_and_backoff(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Cold refresh failures remain visible and do not reschedule on every access."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("broken", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    async def _raise_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        _ = force_reindex
        _ = self
        msg = "cold refresh failed"
        raise RuntimeError(msg)

    monkeypatch.setattr(KnowledgeManager, "reindex_all", _raise_reindex)
    with pytest.raises(RuntimeError, match="cold refresh failed"):
        await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert lookup.index is None
    assert lookup.availability is KnowledgeAvailability.REFRESH_FAILED

    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    first = _resolution.knowledge
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    second = _resolution.knowledge

    assert first is None
    assert second is None
    assert unavailable == {"docs": KnowledgeAvailability.REFRESH_FAILED}
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_refresh_setup_failure_records_failed_availability(tmp_path: Path) -> None:
    """Manager construction failures are persisted instead of leaving cold metadata initializing."""
    docs_path = tmp_path / "docs"
    docs_path.write_text("not a directory", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    with pytest.raises(ValueError, match="must be a directory"):
        await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert state is not None
    assert state.status == "indexing"
    assert state.collection is None
    assert state.refresh_job == "failed"
    assert state.last_error is not None
    assert "must be a directory" in state.last_error
    assert knowledge_registry.published_index_refresh_state(state) == "refresh_failed"
    assert lookup.index is None
    assert lookup.availability is KnowledgeAvailability.REFRESH_FAILED


@pytest.mark.asyncio
async def test_api_delete_marks_index_stale_and_keeps_last_good_best_effort(tmp_path: Path) -> None:
    """DELETE success schedules refresh while old vectors remain usable until refresh publishes."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("delete me now", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    before_delete = resolve_agent_knowledge_access("helper", config, runtime_paths).knowledge
    assert before_delete is not None
    assert [document.content for document in before_delete.search("delete", max_results=5)] == ["delete me now"]

    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = scheduler
    client = TestClient(main.app)

    response = client.delete("/api/knowledge/bases/docs/files/guide.md")
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    after_delete = _resolution.knowledge

    assert response.status_code == 200
    assert after_delete is not None
    assert unavailable == {"docs": KnowledgeAvailability.STALE}
    assert [document.content for document in after_delete.search("delete", max_results=5)] == ["delete me now"]
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_api_replacement_upload_marks_index_stale_and_keeps_last_good_best_effort(
    tmp_path: Path,
) -> None:
    """Replacement uploads leave old vectors usable until refresh publishes."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("old upload", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = scheduler
    client = TestClient(main.app)

    response = client.post(
        "/api/knowledge/bases/docs/upload",
        files=[("files", ("guide.md", b"new upload", "text/markdown"))],
    )
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge

    assert response.status_code == 200
    assert knowledge is not None
    assert unavailable == {"docs": KnowledgeAvailability.STALE}
    assert [document.content for document in knowledge.search("old upload", max_results=5)] == ["old upload"]
    assert [document.content for document in knowledge.search("new upload", max_results=5)] == ["old upload"]
    scheduler.schedule_refresh.assert_called_once()


@pytest.mark.asyncio
async def test_api_upload_failure_does_not_commit_earlier_staged_writes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A failed upload batch leaves the source tree and published index unchanged."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("existing upload", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = scheduler
    monkeypatch.setattr("mindroom.api.knowledge._MAX_UPLOAD_BYTES", 5)
    client = TestClient(main.app)

    response = client.post(
        "/api/knowledge/bases/docs/upload",
        files=[
            ("files", ("guide.md", b"small", "text/markdown")),
            ("files", ("new.md", b"too large", "text/markdown")),
        ],
    )

    assert response.status_code == 413
    unavailable: dict[str, KnowledgeAvailability] = {}
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    knowledge = _resolution.knowledge
    assert knowledge is not None
    assert unavailable == {}
    assert (docs_path / "guide.md").read_text(encoding="utf-8") == "existing upload"
    assert not (docs_path / "new.md").exists()
    assert [document.content for document in knowledge.search("existing upload", max_results=5)] == [
        "existing upload",
    ]
    scheduler.schedule_refresh.assert_not_called()


@pytest.mark.asyncio
async def test_api_status_reports_direct_refresh_runner_reindex(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Status polling should see explicit refresh_knowledge_binding calls, not only scheduler-scheduled jobs."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("refreshing status", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    config_lifecycle.app_state(main.app).knowledge_refresh_scheduler = None
    started = asyncio.Event()
    release = asyncio.Event()

    async def _blocked_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        _ = force_reindex
        _ = self
        started.set()
        await release.wait()
        return RefreshOutcome(indexed_count=0, published=False, error=None)

    monkeypatch.setattr(KnowledgeManager, "reindex_all", _blocked_reindex)
    refresh_task = asyncio.create_task(
        refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths, force_reindex=True),
    )
    await started.wait()
    try:
        client = TestClient(main.app)
        response = client.get("/api/knowledge/bases/docs/status")
    finally:
        release.set()
        await refresh_task

    assert response.status_code == 200
    assert response.json()["refreshing"] is True


@pytest.mark.asyncio
async def test_refresh_scheduler_runs_independent_per_binding_tasks(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Scheduling one binding does not replace, cancel, or wait for another binding."""
    docs_a = tmp_path / "docs-a"
    docs_b = tmp_path / "docs-b"
    config = _config(tmp_path, bases={"a": docs_a, "b": docs_b}, agent_bases=["a", "b"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler(max_concurrent_refreshes=2)
    started: list[str] = []
    release: dict[str, asyncio.Event] = {"a": asyncio.Event(), "b": asyncio.Event()}

    async def _fake_refresh(base_id: str, **_kwargs: object) -> object:
        started.append(base_id)
        await release[base_id].wait()
        if base_id == "a":
            msg = "a failed"
            raise RuntimeError(msg)
        return object()

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess", _fake_refresh)

    scheduler.schedule_refresh("a", config=config, runtime_paths=runtime_paths)
    scheduler.schedule_refresh("a", config=config, runtime_paths=runtime_paths)
    scheduler.schedule_refresh("b", config=config, runtime_paths=runtime_paths)
    await asyncio.sleep(0)

    assert sorted(started) == ["a", "b"]
    assert len(scheduler._tasks) == 2
    release["b"].set()
    await asyncio.sleep(0)
    assert any(key.base_id == "a" for key in scheduler._tasks)
    release["a"].set()
    await scheduler.shutdown()


@pytest.mark.asyncio
async def test_refresh_scheduler_probes_embedder_after_persisted_refresh_failure(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A refresh that persisted REFRESH_FAILED triggers one embedder health probe."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    probe_reasons: list[str] = []

    async def _fake_refresh(base_id: str, **_kwargs: object) -> None:
        key = resolve_published_index_key(base_id, config=config, runtime_paths=runtime_paths, create=True)
        knowledge_registry.mark_published_index_refresh_failed_preserving_last_good(
            key,
            error="Indexed 0 of 3 managed knowledge files (first error: embedder authentication failed (HTTP 401))",
        )

    async def _fake_check(
        _config: Config,
        _runtime_paths: object,
        *,
        reason: str,
        health_recorder: object | None = None,
    ) -> None:
        assert health_recorder is not None
        probe_reasons.append(reason)

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess", _fake_refresh)
    monkeypatch.setattr("mindroom.knowledge.refresh_scheduler.check_embedder_health", _fake_check)

    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    for _ in range(200):
        if probe_reasons:
            break
        await asyncio.sleep(0.01)
    await scheduler.shutdown()

    assert probe_reasons == ["knowledge_refresh_failed"]


@pytest.mark.asyncio
async def test_refresh_scheduler_skips_probe_for_non_embedder_subprocess_failure(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A subprocess crash without causal evidence does not bill an embedding probe."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    probe_reasons: list[str] = []

    async def _fake_refresh(_base_id: str, **_kwargs: object) -> None:
        msg = "subprocess exited 1"
        raise RuntimeError(msg)

    async def _fake_check(
        _config: Config,
        _runtime_paths: object,
        *,
        reason: str,
        health_recorder: object | None = None,
    ) -> None:
        del health_recorder
        probe_reasons.append(reason)

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess", _fake_refresh)
    monkeypatch.setattr("mindroom.knowledge.refresh_scheduler.check_embedder_health", _fake_check)

    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    for _ in range(200):
        if not scheduler._tasks:
            break
        await asyncio.sleep(0.01)
    await scheduler.shutdown()

    assert probe_reasons == []


@pytest.mark.asyncio
async def test_refresh_scheduler_does_not_probe_after_successful_refresh(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A refresh with no persisted failure never triggers a probe."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    refreshed = asyncio.Event()

    async def _fake_refresh(_base_id: str, **_kwargs: object) -> None:
        refreshed.set()

    async def _fake_check(
        _config: Config,
        _runtime_paths: object,
        *,
        reason: str,
        health_recorder: object | None = None,
    ) -> None:
        del health_recorder
        msg = f"unexpected embedder probe: {reason}"
        raise AssertionError(msg)

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess", _fake_refresh)
    monkeypatch.setattr("mindroom.knowledge.refresh_scheduler.check_embedder_health", _fake_check)

    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    await asyncio.wait_for(refreshed.wait(), timeout=5)
    await wait_for_background_tasks(timeout=5)
    await scheduler.shutdown()


@pytest.mark.asyncio
async def test_successful_subprocess_refresh_probes_to_clear_stale_health(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A successful child refresh repairs stale main-process health."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    probe_reasons: list[str] = []
    embedder_health.capture_embedder_health_recorder().record("embedder authentication failed (HTTP 401)")

    async def _fake_refresh(_base_id: str, **_kwargs: object) -> None:
        return None

    async def _fake_check(
        _config: Config,
        _runtime_paths: object,
        *,
        reason: str,
        health_recorder: object | None = None,
    ) -> None:
        assert health_recorder is not None
        probe_reasons.append(reason)

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess", _fake_refresh)
    monkeypatch.setattr("mindroom.knowledge.refresh_scheduler.check_embedder_health", _fake_check)

    try:
        scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
        for _ in range(200):
            if probe_reasons:
                break
            await asyncio.sleep(0.01)
        await scheduler.shutdown()
    finally:
        embedder_health.capture_embedder_health_recorder().record(None)

    assert probe_reasons == ["knowledge_refresh_recovery"]


@pytest.mark.asyncio
async def test_refresh_scheduled_before_reload_cannot_probe_old_config(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A refresh carries the generation captured when it was queued."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    started = asyncio.Event()
    release = asyncio.Event()
    probe_reasons: list[str] = []

    async def _fake_refresh(_base_id: str, **_kwargs: object) -> None:
        started.set()
        await release.wait()

    async def _fake_check(
        _config: Config,
        _runtime_paths: object,
        *,
        reason: str,
        health_recorder: object | None = None,
    ) -> None:
        del health_recorder
        probe_reasons.append(reason)

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess", _fake_refresh)
    monkeypatch.setattr("mindroom.knowledge.refresh_scheduler.check_embedder_health", _fake_check)

    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    await started.wait()
    embedder_health._reset_embedder_health_generation()
    embedder_health.capture_embedder_health_recorder().record("embedder authentication failed (HTTP 401)")
    release.set()
    for _ in range(200):
        if not scheduler._tasks:
            break
        await asyncio.sleep(0.01)
    await wait_for_background_tasks(timeout=5)
    await scheduler.shutdown()
    embedder_health.capture_embedder_health_recorder().record(None)

    assert probe_reasons == []


def test_refresh_scheduler_reads_env_concurrency(monkeypatch: pytest.MonkeyPatch) -> None:
    """The background refresh limit can be tuned from the deployment environment."""
    monkeypatch.setenv("MINDROOM_KNOWLEDGE_REFRESH_CONCURRENCY", "3")

    scheduler = KnowledgeRefreshScheduler()

    assert scheduler.max_concurrent_refreshes == 3


@pytest.mark.parametrize(
    ("raw_value", "match"),
    [
        ("not-an-int", "must be an integer"),
        ("0", "must be at least 1"),
        ("-2", "must be at least 1"),
    ],
)
def test_refresh_scheduler_env_concurrency_fails_fast(
    raw_value: str,
    match: str,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Malformed refresh concurrency env values fail startup instead of hiding typos."""
    monkeypatch.setenv("MINDROOM_KNOWLEDGE_REFRESH_CONCURRENCY", raw_value)

    with pytest.raises(ValueError, match=match):
        KnowledgeRefreshScheduler()


@pytest.mark.asyncio
async def test_refresh_scheduler_coalesces_duplicate_schedule_while_active(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Best-effort schedules run one follow-up refresh with the latest request."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    older_pending_config = config.model_copy(deep=True)
    older_pending_config.knowledge_bases["docs"].chunk_size = 2048
    latest_pending_config = config.model_copy(deep=True)
    latest_pending_config.knowledge_bases["docs"].chunk_size = 4096
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    seen_chunk_sizes: list[int] = []
    first_started = asyncio.Event()
    release_first = asyncio.Event()
    second_started = asyncio.Event()

    async def _fake_refresh(_base_id: str, **kwargs: object) -> object:
        _ = _base_id
        refresh_config = kwargs["config"]
        assert isinstance(refresh_config, Config)
        seen_chunk_sizes.append(refresh_config.knowledge_bases["docs"].chunk_size)
        if len(seen_chunk_sizes) == 1:
            first_started.set()
            await release_first.wait()
        else:
            second_started.set()
        return object()

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess", _fake_refresh)

    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    await first_started.wait()
    scheduler.schedule_refresh("docs", config=older_pending_config, runtime_paths=runtime_paths)
    scheduler.schedule_refresh("docs", config=latest_pending_config, runtime_paths=runtime_paths)
    await asyncio.sleep(0)

    assert seen_chunk_sizes == [5000]

    release_first.set()
    await asyncio.wait_for(second_started.wait(), timeout=1)
    for _attempt in range(50):
        if not scheduler._tasks:
            break
        await asyncio.sleep(0)
    else:
        pytest.fail("coalesced refresh task did not finish")

    assert seen_chunk_sizes == [5000, 4096]


@pytest.mark.asyncio
async def test_refresh_scheduler_refresh_now_runs_directly_with_force_reindex(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Explicit refreshes do not go through the best-effort background queue."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    seen_force_reindex: list[bool] = []

    async def _fake_refresh(base_id: str, **kwargs: object) -> object:
        assert base_id == "docs"
        refresh_config = kwargs["config"]
        assert isinstance(refresh_config, Config)
        seen_force_reindex.append(bool(kwargs.get("force_reindex", False)))
        return knowledge_refresh_runner.KnowledgeRefreshResult(
            key=resolve_published_index_key("docs", config=refresh_config, runtime_paths=runtime_paths),
            indexed_count=1,
            index_published=True,
            availability=KnowledgeAvailability.READY,
        )

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding", _fake_refresh)

    result = await scheduler.refresh_now("docs", config=config, runtime_paths=runtime_paths, force_reindex=True)

    assert result.indexed_count == 1
    assert seen_force_reindex == [True]


@pytest.mark.asyncio
async def test_scheduled_refresh_subprocess_receives_config_snapshot(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The subprocess helper sends the scheduled config snapshot via stdin."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    config.knowledge_bases["docs"].chunk_size = 1234
    runtime_paths = runtime_paths_for(config)
    captured_request: dict[str, object] = {}
    captured_args: tuple[object, ...] = ()
    captured_env: dict[str, str] = {}
    captured_stdin: _Stdin | None = None

    class _Stdin:
        def __init__(self) -> None:
            self.payload = bytearray()

        def write(self, payload: bytes) -> None:
            self.payload.extend(payload)

        async def drain(self) -> None:
            pass

        def close(self) -> None:
            pass

        async def wait_closed(self) -> None:
            pass

    class _Process:
        def __init__(self) -> None:
            self.returncode = 0
            self.stdin = _Stdin()

        async def wait(self) -> int:
            return 0

    async def _fake_create_subprocess_exec(*args: object, **kwargs: object) -> _Process:
        nonlocal captured_args, captured_env, captured_stdin
        captured_args = args
        raw_env = kwargs["env"]
        assert isinstance(raw_env, dict)
        captured_env = raw_env
        assert kwargs["stdin"] is asyncio.subprocess.PIPE
        process = _Process()
        captured_stdin = process.stdin
        return process

    monkeypatch.setattr(knowledge_refresh_runner.asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
    monkeypatch.setattr(knowledge_refresh_runner, "_subprocess_session_kwargs", dict)

    await knowledge_refresh_runner.refresh_knowledge_binding_in_subprocess(
        "docs",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=_identity("@alice:localhost"),
    )

    assert captured_args[:3] == (sys.executable, "-m", "mindroom.knowledge_refresh_runner")
    assert "--request-path" not in captured_args
    assert captured_env["MINDROOM_KNOWLEDGE_REFRESH_SUBPROCESS"] == "1"
    assert captured_stdin is not None
    captured_request.update(json.loads(bytes(captured_stdin.payload).decode()))
    assert captured_request["base_id"] == "docs"
    assert captured_request["config_path"] == str(runtime_paths.config_path)
    assert captured_request["storage_root"] == str(runtime_paths.storage_root)
    assert "runtime_paths" not in captured_request
    assert captured_request["config_data"]["knowledge_bases"]["docs"]["chunk_size"] == 1234
    assert captured_request["runtime_knowledge_base"] is None
    assert captured_request["execution_identity"]["requester_id"] == "@alice:localhost"


@pytest.mark.asyncio
async def test_subprocess_applies_runtime_knowledge_base_after_authored_validation(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A synthetic workspace index may coexist with an authored nested knowledge root."""
    workspace = tmp_path / "workspace"
    thread_exports = workspace / "thread_exports"
    thread_exports.mkdir(parents=True)
    runtime_paths = test_runtime_paths(tmp_path)
    config = Config.validate_with_runtime(
        {
            "models": {},
            "knowledge_bases": {"threads": {"path": str(thread_exports)}},
        },
        runtime_paths,
    )
    base_id = "file_memory_agent_openclaw_test"
    runtime_base = KnowledgeBaseConfig(
        mode="semantic",
        path=str(workspace),
        include_patterns=["memory/**/*.md"],
    )
    effective_config = config.with_runtime_knowledge_base_overlay(base_id, runtime_base)
    payload = knowledge_refresh_runner._serialize_subprocess_refresh_request(
        base_id,
        config=effective_config,
        runtime_paths=runtime_paths,
        execution_identity=None,
        force_reindex=False,
    )
    raw_payload = json.loads(payload)
    assert set(raw_payload["config_data"]["knowledge_bases"]) == {"threads"}
    assert raw_payload["runtime_knowledge_base"]["path"] == str(workspace)

    async def _fake_refresh(
        refresh_base_id: str,
        *,
        config: Config,
        runtime_paths: RuntimePaths,
        **_kwargs: object,
    ) -> knowledge_refresh_runner.KnowledgeRefreshResult:
        assert refresh_base_id == base_id
        assert set(config.knowledge_bases) == {"threads", base_id}
        assert config.knowledge_bases[base_id] == runtime_base
        return knowledge_refresh_runner.KnowledgeRefreshResult(
            key=resolve_published_index_key(base_id, config=config, runtime_paths=runtime_paths),
            indexed_count=0,
            index_published=False,
            availability=KnowledgeAvailability.READY,
        )

    monkeypatch.setattr(knowledge_refresh_runner, "refresh_knowledge_binding", _fake_refresh)

    result = await knowledge_refresh_runner._run_subprocess_refresh_request(payload)

    assert result.availability is KnowledgeAvailability.READY


@pytest.mark.asyncio
async def test_subprocess_refresh_tolerates_broken_unused_plugin(tmp_path: Path) -> None:
    """Child refresh config validation should match startup's broken-plugin tolerance."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "guide.md").write_text("plugin-tolerant refresh", encoding="utf-8")
    plugin_root = tmp_path / "plugins" / "broken"
    plugin_root.mkdir(parents=True)
    (plugin_root / "mindroom.plugin.json").write_text(
        json.dumps({"name": "broken_plugin", "tools_module": "tools.py", "skills": []}),
        encoding="utf-8",
    )
    (plugin_root / "tools.py").write_text("import definitely_missing_refresh_plugin_dependency\n", encoding="utf-8")
    runtime_paths = test_runtime_paths(tmp_path)
    config = Config.validate_with_runtime(
        {
            "agents": {"helper": {"display_name": "Helper", "knowledge_bases": ["docs"]}},
            "models": {},
            "plugins": ["./plugins/broken"],
            "knowledge_bases": {"docs": {"path": str(docs_path)}},
        },
        runtime_paths,
        tolerate_plugin_load_errors=True,
    )
    payload = knowledge_refresh_runner._serialize_subprocess_refresh_request(
        "docs",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=None,
        force_reindex=False,
    )

    result = await knowledge_refresh_runner._run_subprocess_refresh_request(payload)

    assert result.index_published is True
    assert result.availability is KnowledgeAvailability.READY


@pytest.mark.asyncio
async def test_cancelled_subprocess_refresh_reconciles_running_state(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Parent cancellation should not leave child-written metadata stuck as refreshing."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("refresh me", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_stale(key, reason="test_stale")
    initial_state = load_published_index_state(published_index_metadata_path(key))
    assert initial_state is not None
    assert initial_state.refresh_job == "pending"
    wait_entered = asyncio.Event()
    release_wait = asyncio.Event()
    terminated = asyncio.Event()

    class _Stdin:
        def write(self, _payload: bytes) -> None:
            pass

        async def drain(self) -> None:
            pass

        def close(self) -> None:
            pass

        async def wait_closed(self) -> None:
            pass

    class _Process:
        def __init__(self) -> None:
            self.returncode: int | None = None
            self.stdin = _Stdin()

        async def wait(self) -> int:
            knowledge_registry.mark_published_index_refresh_running(key)
            wait_entered.set()
            await release_wait.wait()
            return self.returncode or 0

    async def _fake_create_subprocess_exec(*_args: object, **_kwargs: object) -> _Process:
        return _Process()

    async def _fake_terminate(process: _Process) -> None:
        process.returncode = -15
        release_wait.set()
        terminated.set()

    monkeypatch.setattr(knowledge_refresh_runner.asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
    monkeypatch.setattr(knowledge_refresh_runner, "_terminate_refresh_subprocess", _fake_terminate)

    refresh_task = asyncio.create_task(
        knowledge_refresh_runner.refresh_knowledge_binding_in_subprocess(
            "docs",
            config=config,
            runtime_paths=runtime_paths,
        ),
    )
    await wait_entered.wait()

    refresh_task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await refresh_task

    assert terminated.is_set()
    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.refresh_job == "idle"
    assert state.reason == "refresh_cancelled"


@pytest.mark.asyncio
async def test_failed_subprocess_refresh_reconciles_running_state(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A crashed child should not leave child-written metadata stuck as refreshing."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("refresh me", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_stale(key, reason="test_stale")
    initial_state = load_published_index_state(published_index_metadata_path(key))
    assert initial_state is not None
    assert initial_state.refresh_job == "pending"

    class _Stdin:
        def write(self, _payload: bytes) -> None:
            pass

        async def drain(self) -> None:
            pass

        def close(self) -> None:
            pass

        async def wait_closed(self) -> None:
            pass

    class _Process:
        returncode = 137
        stdin = _Stdin()

        async def wait(self) -> int:
            knowledge_registry.mark_published_index_refresh_running(key)
            return self.returncode

    async def _fake_create_subprocess_exec(*_args: object, **_kwargs: object) -> _Process:
        return _Process()

    monkeypatch.setattr(knowledge_refresh_runner.asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)

    with pytest.raises(RuntimeError, match="exit code 137"):
        await knowledge_refresh_runner.refresh_knowledge_binding_in_subprocess(
            "docs",
            config=config,
            runtime_paths=runtime_paths,
        )

    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.refresh_job == "failed"
    assert state.reason == "refresh_failed"
    assert state.last_error is not None
    assert "exit code 137" in state.last_error


@pytest.mark.asyncio
async def test_failed_subprocess_refresh_does_not_overwrite_newer_success(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A stale failed parent must not mark a newer successful publish as failed."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("refresh me", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_stale(key, reason="test_stale")

    class _Stdin:
        def write(self, _payload: bytes) -> None:
            pass

        async def drain(self) -> None:
            pass

        def close(self) -> None:
            pass

        async def wait_closed(self) -> None:
            pass

    class _Process:
        returncode = 137
        stdin = _Stdin()

        async def wait(self) -> int:
            knowledge_registry.mark_published_index_refresh_running(key)
            knowledge_registry.save_published_index_state(
                published_index_metadata_path(key),
                knowledge_registry.PublishedIndexState(
                    settings=key.indexing_settings,
                    status="complete",
                    collection="newer-success",
                    last_published_at="2026-04-28T00:00:00+00:00",
                    published_revision="newer-revision",
                    indexed_count=1,
                    source_signature="newer-source",
                    refresh_job="idle",
                ),
            )
            return self.returncode

    async def _fake_create_subprocess_exec(*_args: object, **_kwargs: object) -> _Process:
        return _Process()

    monkeypatch.setattr(knowledge_refresh_runner.asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)

    with pytest.raises(RuntimeError, match="exit code 137"):
        await knowledge_refresh_runner.refresh_knowledge_binding_in_subprocess(
            "docs",
            config=config,
            runtime_paths=runtime_paths,
        )

    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.status == "complete"
    assert state.collection == "newer-success"
    assert state.refresh_job == "idle"
    assert state.reason is None
    assert state.last_error is None


@pytest.mark.asyncio
async def test_failed_subprocess_refresh_reconciles_running_state_after_newer_publish(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A child crash must clear the running marker it writes on top of a newer publish."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("refresh me", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge_registry.mark_published_index_stale(key, reason="test_stale")

    class _Stdin:
        def write(self, _payload: bytes) -> None:
            pass

        async def drain(self) -> None:
            pass

        def close(self) -> None:
            pass

        async def wait_closed(self) -> None:
            pass

    class _Process:
        returncode = 137
        stdin = _Stdin()

        async def wait(self) -> int:
            knowledge_registry.save_published_index_state(
                published_index_metadata_path(key),
                knowledge_registry.PublishedIndexState(
                    settings=key.indexing_settings,
                    status="complete",
                    collection="newer-success",
                    last_published_at="2026-04-28T00:00:00+00:00",
                    published_revision="newer-revision",
                    indexed_count=1,
                    source_signature="newer-source",
                    refresh_job="idle",
                ),
            )
            knowledge_registry.mark_published_index_refresh_running(key)
            return self.returncode

    async def _fake_create_subprocess_exec(*_args: object, **_kwargs: object) -> _Process:
        return _Process()

    monkeypatch.setattr(knowledge_refresh_runner.asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)

    with pytest.raises(RuntimeError, match="exit code 137"):
        await knowledge_refresh_runner.refresh_knowledge_binding_in_subprocess(
            "docs",
            config=config,
            runtime_paths=runtime_paths,
        )

    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.status == "complete"
    assert state.collection == "newer-success"
    assert state.refresh_job == "failed"
    assert state.reason == "refresh_failed"
    assert state.last_error is not None
    assert "exit code 137" in state.last_error


@pytest.mark.asyncio
async def test_refresh_subprocess_receives_conservative_thread_env(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Refresh child processes should not inherit unbounded math/tokenizer thread settings."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    captured_env: dict[str, str] = {}

    class _Stdin:
        def write(self, _payload: bytes) -> None:
            pass

        async def drain(self) -> None:
            pass

        def close(self) -> None:
            pass

        async def wait_closed(self) -> None:
            pass

    class _Process:
        returncode = 0
        stdin = _Stdin()

        async def wait(self) -> int:
            return self.returncode

    async def _fake_create_subprocess_exec(*_args: object, **kwargs: object) -> _Process:
        captured_env.update(kwargs["env"])
        return _Process()

    monkeypatch.setattr(knowledge_refresh_runner.asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)

    await knowledge_refresh_runner.refresh_knowledge_binding_in_subprocess(
        "docs",
        config=config,
        runtime_paths=runtime_paths,
    )

    assert captured_env["OMP_NUM_THREADS"] == "1"
    assert captured_env["OPENBLAS_NUM_THREADS"] == "1"
    assert captured_env["MKL_NUM_THREADS"] == "1"
    assert captured_env["NUMEXPR_NUM_THREADS"] == "1"
    assert captured_env["VECLIB_MAXIMUM_THREADS"] == "1"
    assert captured_env["TOKENIZERS_PARALLELISM"] == "false"


@pytest.mark.asyncio
async def test_refresh_scheduler_shutdown_suppresses_completed_refresh_failures(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Shutdown drains fire-and-forget refresh task failures instead of re-raising them."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()

    async def _fake_refresh(base_id: str, **_kwargs: object) -> object:
        _ = base_id
        msg = "refresh failed"
        raise RuntimeError(msg)

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess", _fake_refresh)

    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    await asyncio.sleep(0)
    await scheduler.shutdown()


@pytest.mark.asyncio
async def test_refresh_scheduler_does_not_schedule_after_shutdown(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Late schedule calls after shutdown do not create orphaned refresh tasks."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    calls = 0

    async def _fake_refresh(base_id: str, **_kwargs: object) -> object:
        _ = base_id
        nonlocal calls
        calls += 1
        return object()

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding", _fake_refresh)

    await scheduler.shutdown()
    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    await asyncio.sleep(0)

    assert calls == 0
    assert scheduler._tasks == {}


@pytest.mark.asyncio
async def test_refresh_status_is_visible_across_scheduler_instances(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Dashboard status schedulers should see refreshes started by the Matrix/orchestrator scheduler."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    matrix_scheduler = KnowledgeRefreshScheduler()
    api_scheduler = KnowledgeRefreshScheduler()
    started = asyncio.Event()
    release = asyncio.Event()

    async def _blocked_refresh(base_id: str, **_kwargs: object) -> object:
        _ = base_id
        started.set()
        await release.wait()
        return object()

    monkeypatch.setattr(
        "mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess",
        _blocked_refresh,
    )

    matrix_scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    await started.wait()

    try:
        assert api_scheduler.is_refreshing("docs", config=config, runtime_paths=runtime_paths) is True
    finally:
        release.set()
        await matrix_scheduler.shutdown()
        await api_scheduler.shutdown()


@pytest.mark.asyncio
async def test_refresh_scheduler_claim_is_exclusive_across_instances(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Two schedulers must not launch duplicate refreshes for one physical binding."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    matrix_scheduler = KnowledgeRefreshScheduler()
    api_scheduler = KnowledgeRefreshScheduler()
    started = asyncio.Event()
    release = asyncio.Event()
    calls = 0

    async def _blocked_refresh(_base_id: str, **_kwargs: object) -> object:
        nonlocal calls
        calls += 1
        started.set()
        await release.wait()
        return object()

    monkeypatch.setattr(
        "mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess",
        _blocked_refresh,
    )

    matrix_scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    api_scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    await asyncio.wait_for(started.wait(), timeout=1)
    await asyncio.sleep(0)

    try:
        assert calls == 1
        assert len(matrix_scheduler._tasks) + len(api_scheduler._tasks) == 1
        assert matrix_scheduler._pending == {}
        assert api_scheduler._pending == {}
        assert matrix_scheduler._claim_retry_handles == {}
        assert api_scheduler._claim_retry_handles == {}
    finally:
        release.set()
        await matrix_scheduler.shutdown()
        await api_scheduler.shutdown()


@pytest.mark.asyncio
async def test_refresh_scheduler_retries_request_after_direct_refresh_owner_finishes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A request rejected by a direct refresh claim must run after that owner finishes."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    refresh_target = knowledge_registry.resolve_refresh_target(
        "docs",
        config=config,
        runtime_paths=runtime_paths,
    )
    started = asyncio.Event()
    calls = 0

    async def _record_refresh(_base_id: str, **_kwargs: object) -> object:
        nonlocal calls
        calls += 1
        started.set()
        return object()

    monkeypatch.setattr(
        "mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess",
        _record_refresh,
    )

    knowledge_refresh_locks.mark_refresh_active(refresh_target)
    direct_claim_active = True
    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)

    try:
        await asyncio.sleep(0)
        assert calls == 0
        knowledge_refresh_locks.mark_refresh_inactive(refresh_target)
        direct_claim_active = False
        await asyncio.wait_for(started.wait(), timeout=1)
        assert calls == 1
    finally:
        if direct_claim_active:
            knowledge_refresh_locks.mark_refresh_inactive(refresh_target)
        await scheduler.shutdown()


@pytest.mark.asyncio
async def test_refresh_scheduler_limits_concurrent_subprocess_refreshes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Queued refreshes should not start more child refresh workers than the configured global limit."""
    docs_path = tmp_path / "docs"
    api_path = tmp_path / "api"
    config = _config(
        tmp_path,
        bases={"docs": docs_path, "api": api_path},
        agent_bases=["docs", "api"],
    )
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler(max_concurrent_refreshes=1)
    first_started = asyncio.Event()
    second_started = asyncio.Event()
    release_first = asyncio.Event()
    started_base_ids: list[str] = []
    active_refreshes = 0
    max_active_refreshes = 0

    async def _blocked_refresh(base_id: str, **_kwargs: object) -> object:
        nonlocal active_refreshes, max_active_refreshes
        started_base_ids.append(base_id)
        active_refreshes += 1
        max_active_refreshes = max(max_active_refreshes, active_refreshes)
        try:
            if len(started_base_ids) == 1:
                first_started.set()
                await release_first.wait()
            else:
                second_started.set()
            return object()
        finally:
            active_refreshes -= 1

    monkeypatch.setattr(
        "mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess",
        _blocked_refresh,
    )

    scheduler.schedule_refresh("docs", config=config, runtime_paths=runtime_paths)
    scheduler.schedule_refresh("api", config=config, runtime_paths=runtime_paths)
    await first_started.wait()
    await asyncio.sleep(0)

    assert started_base_ids == ["docs"]
    assert second_started.is_set() is False

    release_first.set()
    await second_started.wait()
    await scheduler.shutdown()

    assert started_base_ids == ["docs", "api"]
    assert max_active_refreshes == 1


def test_index_key_is_per_binding_not_raw_base_id(tmp_path: Path) -> None:
    """The same base id resolves to separate refresh keys when storage binding differs."""
    path = tmp_path / "docs"
    config_a = _config(tmp_path / "a", bases={"docs": path}, agent_bases=["docs"])
    config_b = _config(tmp_path / "b", bases={"docs": path}, agent_bases=["docs"])

    key_a = get_published_index("docs", config=config_a, runtime_paths=runtime_paths_for(config_a)).key
    key_b = get_published_index("docs", config=config_b, runtime_paths=runtime_paths_for(config_b)).key

    assert key_a.base_id == key_b.base_id == "docs"
    assert key_a != key_b


def test_shared_knowledge_path_named_memory_stays_config_relative(tmp_path: Path) -> None:
    """A shared KB named like memory should not bind to agent file-memory workspaces."""
    runtime_paths = test_runtime_paths(tmp_path)
    config = bind_runtime_paths(
        Config(
            agents={
                "openclaw": AgentConfig(
                    display_name="OpenClaw",
                    memory_backend="file",
                    knowledge_bases=["daily_memory"],
                ),
                "mindroom_spouse": AgentConfig(
                    display_name="MindRoom Spouse",
                    memory_backend="file",
                    knowledge_bases=["daily_memory"],
                ),
            },
            models={},
            knowledge_bases={"daily_memory": KnowledgeBaseConfig(path="./memory")},
            memory={"backend": "file", "file": {"path": "./memory"}},
        ),
        runtime_paths,
    )

    dashboard_root = knowledge_api._knowledge_root(config, "daily_memory", runtime_paths, create=True)
    shared_key = resolve_published_index_key("daily_memory", config=config, runtime_paths=runtime_paths, create=True)
    openclaw_key = resolve_published_index_key(
        "daily_memory",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=_identity("@alice:localhost", agent_name="openclaw"),
        create=True,
    )
    spouse_key = resolve_published_index_key(
        "daily_memory",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=_identity("@alice:localhost", agent_name="mindroom_spouse"),
        create=True,
    )

    assert dashboard_root == runtime_paths.config_dir / "memory"
    assert Path(shared_key.knowledge_path) == runtime_paths.config_dir / "memory"
    assert Path(openclaw_key.knowledge_path) == runtime_paths.config_dir / "memory"
    assert Path(spouse_key.knowledge_path) == runtime_paths.config_dir / "memory"
    assert {shared_key, openclaw_key, spouse_key} == {shared_key}
    assert not (runtime_paths.storage_root / "agents" / "openclaw" / "workspace" / "memory").exists()
    assert not (runtime_paths.storage_root / "agents" / "mindroom_spouse" / "workspace" / "memory").exists()


def test_shared_relative_knowledge_base_keeps_non_memory_path_config_relative(tmp_path: Path) -> None:
    """Ordinary shared relative knowledge paths should stay config-relative."""
    runtime_paths = test_runtime_paths(tmp_path)
    config = Config(
        agents={
            "helper": AgentConfig(
                display_name="Helper",
                memory_backend="file",
                knowledge_bases=["docs"],
            ),
        },
        models={},
        knowledge_bases={"docs": KnowledgeBaseConfig(path="./knowledge_docs")},
        memory={"backend": "file", "file": {"path": "./memory"}},
    )
    config = bind_runtime_paths(config, runtime_paths)

    key = resolve_published_index_key(
        "docs",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=_identity("@alice:localhost"),
        create=True,
    )

    assert Path(key.knowledge_path) == runtime_paths.config_dir / "knowledge_docs"


def test_shared_literal_dollar_path_stays_config_relative(tmp_path: Path) -> None:
    """Literal dollar signs in path names should stay ordinary config-relative knowledge paths."""
    runtime_paths = test_runtime_paths(tmp_path)
    config = Config(
        agents={
            "helper": AgentConfig(
                display_name="Helper",
                memory_backend="file",
                knowledge_bases=["daily_memory"],
            ),
        },
        models={},
        knowledge_bases={"daily_memory": KnowledgeBaseConfig(path="./notes$archive")},
        memory={"backend": "file", "file": {"path": "./notes$archive"}},
    )
    config = bind_runtime_paths(config, runtime_paths)

    key = resolve_published_index_key(
        "daily_memory",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=_identity("@alice:localhost"),
        create=True,
    )

    assert Path(key.knowledge_path) == runtime_paths.config_dir / "notes$archive"


def test_file_memory_agent_without_configured_file_path_keeps_shared_base_config_relative(tmp_path: Path) -> None:
    """Agent-level file memory does not make arbitrary shared paths workspace-relative."""
    runtime_paths = test_runtime_paths(tmp_path)
    config = Config(
        agents={
            "helper": AgentConfig(
                display_name="Helper",
                memory_backend="file",
                knowledge_bases=["daily_memory"],
            ),
        },
        models={},
        knowledge_bases={"daily_memory": KnowledgeBaseConfig(path="./memory")},
        memory={"backend": "mem0"},
    )
    config = bind_runtime_paths(config, runtime_paths)

    key = resolve_published_index_key(
        "daily_memory",
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=_identity("@alice:localhost"),
        create=True,
    )

    assert Path(key.knowledge_path) == runtime_paths.config_dir / "memory"


@pytest.mark.asyncio
async def test_private_agent_knowledge_publishes_isolated_indexes(tmp_path: Path) -> None:
    """Requester-local private knowledge must resolve to separate physical index bindings."""
    runtime_paths = test_runtime_paths(tmp_path)
    config = bind_runtime_paths(
        Config(
            agents={
                "helper": AgentConfig(
                    display_name="Helper",
                    private=AgentPrivateConfig(
                        per="user",
                        root="mind_data",
                        knowledge=AgentPrivateKnowledgeConfig(path="knowledge"),
                    ),
                ),
            },
            models={},
        ),
        runtime_paths,
    )
    base_id = config.resolve_entity("helper").private_knowledge_base_id
    assert base_id is not None
    identity_a = _identity("@alice:localhost")
    identity_b = _identity("@bob:localhost")
    key_a = resolve_published_index_key(
        base_id,
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=identity_a,
        create=True,
    )
    key_b = resolve_published_index_key(
        base_id,
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=identity_b,
        create=True,
    )
    Path(key_a.knowledge_path).mkdir(parents=True, exist_ok=True)
    Path(key_b.knowledge_path).mkdir(parents=True, exist_ok=True)
    (Path(key_a.knowledge_path) / "note.md").write_text("alice private note", encoding="utf-8")
    (Path(key_b.knowledge_path) / "note.md").write_text("bob private note", encoding="utf-8")

    await refresh_knowledge_binding(base_id, config=config, runtime_paths=runtime_paths, execution_identity=identity_a)
    await refresh_knowledge_binding(base_id, config=config, runtime_paths=runtime_paths, execution_identity=identity_b)
    knowledge_a = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        execution_identity=identity_a,
    ).knowledge
    knowledge_b = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        execution_identity=identity_b,
    ).knowledge

    assert key_a != key_b
    assert knowledge_a is not None
    assert knowledge_b is not None
    assert [document.content for document in knowledge_a.search("private", max_results=5)] == ["alice private note"]
    assert [document.content for document in knowledge_b.search("private", max_results=5)] == ["bob private note"]


@pytest.mark.asyncio
async def test_private_agent_knowledge_schedules_refresh_when_source_changes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Requester-local READY indexes should be served and refreshed without request-time scans."""
    runtime_paths = test_runtime_paths(tmp_path)
    config = bind_runtime_paths(
        Config(
            agents={
                "helper": AgentConfig(
                    display_name="Helper",
                    private=AgentPrivateConfig(
                        per="user",
                        root="mind_data",
                        knowledge=AgentPrivateKnowledgeConfig(path="knowledge"),
                    ),
                ),
            },
            models={},
        ),
        runtime_paths,
    )
    base_id = config.resolve_entity("helper").private_knowledge_base_id
    assert base_id is not None
    identity = _identity("@alice:localhost")
    key = resolve_published_index_key(
        base_id,
        config=config,
        runtime_paths=runtime_paths,
        execution_identity=identity,
        create=True,
    )
    knowledge_path = Path(key.knowledge_path)
    knowledge_path.mkdir(parents=True, exist_ok=True)
    note = knowledge_path / "note.md"
    note.write_text("alice private old", encoding="utf-8")

    await refresh_knowledge_binding(base_id, config=config, runtime_paths=runtime_paths, execution_identity=identity)
    note.write_text("alice private new", encoding="utf-8")
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()
    unavailable: dict[str, KnowledgeAvailability] = {}
    unavailable_details: dict[str, KnowledgeAvailabilityDetail] = {}

    def _unexpected_signature(*_args: object, **_kwargs: object) -> str:
        msg = "private READY access should not scan the local corpus"
        raise AssertionError(msg)

    monkeypatch.setattr("mindroom.knowledge.manager._knowledge_source_signature", _unexpected_signature)
    monkeypatch.setattr(knowledge_utils, "_knowledge_source_signature", _unexpected_signature, raising=False)
    _resolution = resolve_agent_knowledge_access(
        "helper",
        config,
        runtime_paths,
        execution_identity=identity,
        refresh_scheduler=scheduler,
    )
    unavailable.update({base_id: detail.availability for (base_id, detail) in _resolution.unavailable.items()})
    unavailable_details.update(_resolution.unavailable)
    knowledge = _resolution.knowledge

    assert knowledge is not None
    assert [document.content for document in knowledge.search("private", max_results=5)] == ["alice private old"]
    assert unavailable == {base_id: KnowledgeAvailability.STALE}
    assert unavailable_details == {
        base_id: KnowledgeAvailabilityDetail(
            availability=KnowledgeAvailability.STALE,
            search_available=True,
        ),
    }
    scheduler.schedule_refresh.assert_called_once()


def test_private_agent_knowledge_bookkeeping_is_bounded(tmp_path: Path) -> None:
    """Private index, lock, and refresh-cooldown registries should be pruned."""
    runtime_paths = test_runtime_paths(tmp_path)
    config = bind_runtime_paths(
        Config(
            agents={
                "helper": AgentConfig(
                    display_name="Helper",
                    private=AgentPrivateConfig(
                        per="user",
                        root="mind_data",
                        knowledge=AgentPrivateKnowledgeConfig(path="knowledge"),
                    ),
                ),
            },
            models={},
        ),
        runtime_paths,
    )
    base_id = config.resolve_entity("helper").private_knowledge_base_id
    assert base_id is not None
    max_entries = max(
        knowledge_registry._MAX_PRIVATE_PUBLISHED_INDEXES,
        knowledge_utils._MAX_REFRESH_SCHEDULED_COOLDOWNS,
        knowledge_refresh_locks._MAX_REFRESH_LOCKS,
    )
    scheduler = MagicMock()
    scheduler.is_refreshing = MagicMock(return_value=False)
    scheduler.schedule_refresh = MagicMock()

    for index in range(max_entries + 40):
        identity = _identity(f"@user{index}:localhost")
        key = resolve_published_index_key(
            base_id,
            config=config,
            runtime_paths=runtime_paths,
            execution_identity=identity,
            create=True,
        )
        collection = f"private_collection_{index}"
        refresh_target = knowledge_registry.refresh_target_for_published_index_key(key)
        knowledge_registry._publish_knowledge_index(
            key,
            knowledge=_Knowledge(_VectorDb(collection=collection)),
            state=knowledge_registry.PublishedIndexState(
                settings=key.indexing_settings,
                status="complete",
                collection=collection,
                source_signature=f"sig-{index}",
            ),
            metadata_path=published_index_metadata_path(key),
        )
        # Stamp through the production path, so deleting its prune call fails here.
        knowledge_utils._schedule_refresh_for_availability(
            scheduler,
            base_id,
            config=config,
            runtime_paths=runtime_paths,
            execution_identity=identity,
            lookup=get_published_index(
                base_id,
                config=config,
                runtime_paths=runtime_paths,
                execution_identity=identity,
            ),
            availability=KnowledgeAvailability.STALE,
            wall_now=datetime.now(tz=UTC),
        )
        _create_idle_refresh_lock(knowledge_registry.source_root_for_refresh_target(refresh_target))

    private_index_count = sum(
        key.base_id.startswith(config.PRIVATE_KNOWLEDGE_BASE_ID_PREFIX) for key in knowledge_registry._published_indexes
    )
    assert private_index_count <= knowledge_registry._MAX_PRIVATE_PUBLISHED_INDEXES
    assert len(knowledge_utils._refresh_scheduled_at) <= knowledge_utils._MAX_REFRESH_SCHEDULED_COOLDOWNS
    assert len(knowledge_refresh_locks._refresh_locks) <= knowledge_refresh_locks._MAX_REFRESH_LOCKS


def test_private_index_read_path_cache_insertion_is_bounded(tmp_path: Path) -> None:
    """Loading persisted private indexes through the read path should prune old cache entries."""
    runtime_paths = test_runtime_paths(tmp_path)
    config = bind_runtime_paths(
        Config(
            agents={
                "helper": AgentConfig(
                    display_name="Helper",
                    private=AgentPrivateConfig(
                        per="user",
                        root="mind_data",
                        knowledge=AgentPrivateKnowledgeConfig(path="knowledge"),
                    ),
                ),
            },
            models={},
        ),
        runtime_paths,
    )
    base_id = config.resolve_entity("helper").private_knowledge_base_id
    assert base_id is not None
    count = knowledge_registry._MAX_PRIVATE_PUBLISHED_INDEXES + 10

    for index in range(count):
        identity = _identity(f"@user{index}:localhost")
        key = resolve_published_index_key(
            base_id,
            config=config,
            runtime_paths=runtime_paths,
            execution_identity=identity,
            create=True,
        )
        collection = f"private_read_collection_{index}"
        _VectorDb.collections[collection] = [
            {"content": f"private read {index}", "metadata": {"source_path": "note.md"}},
        ]
        knowledge_registry.save_published_index_state(
            published_index_metadata_path(key),
            knowledge_registry.PublishedIndexState(
                settings=key.indexing_settings,
                status="complete",
                collection=collection,
                indexed_count=1,
                source_signature=f"sig-{index}",
            ),
        )
        knowledge_registry.mark_published_index_refresh_succeeded(key)

    knowledge_registry._published_indexes.clear()

    for index in range(count):
        lookup = get_published_index(
            base_id,
            config=config,
            runtime_paths=runtime_paths,
            execution_identity=_identity(f"@user{index}:localhost"),
        )
        assert lookup.index is not None

    private_index_count = sum(
        key.base_id.startswith(config.PRIVATE_KNOWLEDGE_BASE_ID_PREFIX) for key in knowledge_registry._published_indexes
    )
    assert private_index_count <= knowledge_registry._MAX_PRIVATE_PUBLISHED_INDEXES


def test_publish_knowledge_index_caches_handle_without_collection_leases(tmp_path: Path) -> None:
    """Published indexs use only the active cache, not reader lease bookkeeping."""

    class _NonWeakrefKnowledge:
        __slots__ = ()

    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    knowledge = _NonWeakrefKnowledge()

    index = knowledge_registry._publish_knowledge_index(
        key,
        knowledge=knowledge,
        state=knowledge_registry.PublishedIndexState(
            settings=key.indexing_settings,
            status="complete",
            collection="non_weakref_collection",
        ),
        metadata_path=published_index_metadata_path(key),
    )

    assert knowledge_registry._published_indexes[key] is index


def _write_queryable_index_state(
    key: knowledge_registry.PublishedIndexKey,
    *,
    collection: str,
) -> None:
    _VectorDb.collections[collection] = []
    published_index_metadata_path(key).parent.mkdir(parents=True, exist_ok=True)
    knowledge_registry.save_published_index_state(
        published_index_metadata_path(key),
        knowledge_registry.PublishedIndexState(
            settings=key.indexing_settings,
            status="complete",
            collection=collection,
            indexed_count=0,
            source_signature="credential-rotation-test",
        ),
    )


def test_cached_handle_rebuilds_after_dashboard_embedder_credential_rotation(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Credential save/delete replaces the cached client without rebuilding vectors."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    _write_queryable_index_state(key, collection="credential_rotation")
    manager = get_runtime_shared_credentials_manager(runtime_paths)
    manager.save_credentials("openai", {"api_key": "fallback-key"})
    manager.save_credentials("embedder", {"api_key": "old-key"})
    constructed_keys: list[str] = []

    def capture_embedder(_config: Config, _runtime_paths: RuntimePaths) -> object:
        constructed_keys.append(get_embedder_api_key(_runtime_paths))
        return object()

    monkeypatch.setattr(knowledge_registry, "create_configured_embedder", capture_embedder)

    first = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    manager.save_credentials("embedder", {"api_key": "new-key"})
    second = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    manager.delete_credentials("embedder")
    third = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert first.index is not None
    assert second.index is not None
    assert third.index is not None
    assert first.index is not second.index
    assert second.index is not third.index
    assert constructed_keys == ["old-key", "new-key", "fallback-key"]
    assert first.index.embedder_client_signature is not None
    assert second.index.embedder_client_signature is not None
    assert first.index.embedder_client_signature != second.index.embedder_client_signature
    assert "old-key" not in first.index.embedder_client_signature
    assert "new-key" not in second.index.embedder_client_signature


def test_cached_handle_rebuilds_after_explicit_embedder_key_reload(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A config hot reload replaces a handle that captured the old explicit key."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    old_config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        memory={"embedder": {"config": {"api_key": "explicit-old"}}},
    )
    new_config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        memory={"embedder": {"config": {"api_key": "explicit-new"}}},
    )
    runtime_paths = runtime_paths_for(old_config)
    key = resolve_published_index_key("docs", config=old_config, runtime_paths=runtime_paths)
    assert key == resolve_published_index_key("docs", config=new_config, runtime_paths=runtime_paths)
    _write_queryable_index_state(key, collection="explicit_key_rotation")
    constructed_keys: list[str] = []

    def capture_embedder(config: Config, _runtime_paths: RuntimePaths) -> object:
        constructed_keys.append(
            get_embedder_api_key(
                _runtime_paths,
                explicit_api_key=config.memory.embedder.config.api_key,
            ),
        )
        return object()

    monkeypatch.setattr(knowledge_registry, "create_configured_embedder", capture_embedder)

    first = get_published_index("docs", config=old_config, runtime_paths=runtime_paths)
    second = get_published_index("docs", config=new_config, runtime_paths=runtime_paths)

    assert first.index is not None
    assert second.index is not None
    assert first.index is not second.index
    assert constructed_keys == ["explicit-old", "explicit-new"]


@pytest.mark.asyncio
async def test_published_indexed_count_uses_persisted_metadata_without_collection_scan(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Routine status counts come from metadata rather than scanning vector rows."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert lookup.index is not None

    def _raise_scan(self: _Client, name: str) -> _Collection:
        _ = (self, name)
        msg = "collection scan should not be used"
        raise AssertionError(msg)

    monkeypatch.setattr(_Client, "get_collection", _raise_scan)

    assert (lookup.index.state.indexed_count or 0) == 1


@pytest.mark.asyncio
async def test_local_noop_refresh_reports_published_index(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    """An unchanged local refresh republishes a usable index and reports it as published."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("local index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    reindex_count = 0
    original_reindex = KnowledgeManager.reindex_all

    async def _track_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        nonlocal reindex_count
        reindex_count += 1
        if reindex_count > 1:
            msg = "unchanged local refresh should not reindex"
            raise AssertionError(msg)
        return await original_reindex(self, force_reindex=force_reindex)

    monkeypatch.setattr(KnowledgeManager, "reindex_all", _track_reindex)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is True
    assert result.indexed_count == 1
    assert reindex_count == 1


@pytest.mark.asyncio
async def test_local_refresh_reindexes_when_content_changes_with_same_mtime_and_size(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The unchanged fast path must not publish stale vectors after content-only changes."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("old index", encoding="utf-8")
    initial_stat = doc.stat()
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    reindex_count = 0
    original_reindex = KnowledgeManager.reindex_all

    async def _track_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        nonlocal reindex_count
        reindex_count += 1
        return await original_reindex(self, force_reindex=force_reindex)

    monkeypatch.setattr(KnowledgeManager, "reindex_all", _track_reindex)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("new index", encoding="utf-8")
    os.utime(doc, ns=(initial_stat.st_atime_ns, initial_stat.st_mtime_ns))
    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is True
    assert reindex_count == 2
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("index", max_results=5)] == [
        "new index",
    ]


@pytest.mark.asyncio
async def test_refresh_does_not_synthesize_missing_published_metadata(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A missing publish pointer after refresh leaves published unavailable instead of creating READY metadata."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("metadata index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    original_reindex = KnowledgeManager.reindex_all

    async def _delete_metadata_after_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        outcome = await original_reindex(self, force_reindex=force_reindex)
        self._indexing_settings_path.unlink()
        return outcome

    monkeypatch.setattr(KnowledgeManager, "reindex_all", _delete_metadata_after_reindex)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))

    assert result.index_published is False
    assert result.availability is KnowledgeAvailability.REFRESH_FAILED
    assert state is not None
    assert state.status == "failed"
    assert state.collection is None
    assert state.last_error == "Published index metadata was missing after refresh"
    assert knowledge_registry.published_index_refresh_state(state) == "refresh_failed"


def test_published_metadata_write_uses_unique_temp_and_cleans_failed_replace(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Published metadata writes should not share one deterministic temp file."""
    metadata_path = tmp_path / "indexing_settings.json"
    attempted_temp_paths: list[Path] = []
    original_replace = Path.replace

    def _fail_temp_replace(self: Path, target: Path) -> Path:
        if self.parent == tmp_path and self.name.startswith(".indexing_settings.json.") and self.name.endswith(".tmp"):
            attempted_temp_paths.append(self)
            msg = "replace failed"
            raise OSError(msg)
        return original_replace(self, target)

    monkeypatch.setattr(Path, "replace", _fail_temp_replace)

    with pytest.raises(OSError, match="replace failed"):
        knowledge_registry.save_published_index_state(
            metadata_path,
            knowledge_registry.PublishedIndexState(
                settings=_test_indexing_settings(),
                status="complete",
                collection="collection",
                source_signature="signature",
            ),
        )

    assert attempted_temp_paths
    assert attempted_temp_paths[0].name != "indexing_settings.json.tmp"
    assert not attempted_temp_paths[0].exists()


@pytest.mark.asyncio
async def test_git_refresh_syncs_before_reindex_and_publishes_revision_without_secret_metadata(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Git-backed refresh syncs first, publishes the revision, and persists no URL userinfo."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("git index", encoding="utf-8")
    git_config = KnowledgeGitConfig(
        repo_url="https://ghp_secret:x-oauth-basic@example.com/org/repo.git",
        branch="main",
    )
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    order: list[str] = []
    original_reindex = KnowledgeManager.reindex_all

    async def _sync_success(self: GitKnowledgeSource) -> GitSyncResult:
        order.append("sync")
        return _record_git_sync(self, GitSyncResult(head="rev-git", updated=True), "doc.md")

    async def _track_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        order.append("reindex")
        return await original_reindex(self, force_reindex=force_reindex)

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_success)
    monkeypatch.setattr(KnowledgeManager, "reindex_all", _track_reindex)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    metadata_text = published_index_metadata_path(key).read_text(encoding="utf-8")

    assert result.index_published is True
    assert order == ["sync", "reindex"]
    assert state is not None
    assert state.published_revision == "rev-git"
    assert state.source_signature == _knowledge_source_signature(
        config,
        "docs",
        docs_path,
        tracked_relative_paths={"doc.md"},
    )
    assert "ghp_secret" not in metadata_text
    assert "x-oauth-basic" not in metadata_text


def _git_noop_config(tmp_path: Path, *, files: tuple[str, ...] = ("doc.md",)) -> tuple[Config, RuntimePaths]:
    """Build a Git-backed base used by the revision-gating tests."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    for name in files:
        (docs_path / name).write_text("git index", encoding="utf-8")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")},
    )
    return config, runtime_paths_for(config)


def _install_git_sync_results(
    monkeypatch: pytest.MonkeyPatch,
    results: list[GitSyncResult],
    *,
    tracked: tuple[str, ...] = ("doc.md",),
) -> None:
    """Drive Git source sync through a fixed sequence of poll outcomes."""

    async def _sync(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, results.pop(0), *tracked)

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync)


def _install_git_revisions(monkeypatch: pytest.MonkeyPatch, revisions: list[str | None]) -> None:
    """Return a fixed sequence of ``git rev-parse`` results, repeating the last."""
    pending = list(revisions)

    async def _rev_parse(self: KnowledgeManager, ref: str) -> str | None:
        del self, ref
        return pending.pop(0) if len(pending) > 1 else pending[0]

    monkeypatch.setattr(GitKnowledgeSource, "_rev_parse", _rev_parse)


@dataclass
class _SignatureCounter:
    """Count corpus-hash calls made through one module's imported binding."""

    calls: int = 0


def _install_counting_signature(monkeypatch: pytest.MonkeyPatch, module: ModuleType) -> _SignatureCounter:
    """Count ``_knowledge_source_signature`` calls without changing its behavior."""
    counter = _SignatureCounter()
    original_signature = module._knowledge_source_signature

    def _counting_signature(
        config: Config,
        base_id: str,
        knowledge_root: Path,
        *,
        tracked_relative_paths: Iterable[str] | None = None,
    ) -> str:
        counter.calls += 1
        return original_signature(config, base_id, knowledge_root, tracked_relative_paths=tracked_relative_paths)

    monkeypatch.setattr(module, "_knowledge_source_signature", _counting_signature)
    return counter


@pytest.mark.asyncio
async def test_git_noop_refresh_skips_full_reindex_when_index_is_complete(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """An unchanged Git poll should update sync metadata without rebuilding the collection."""
    config, runtime_paths = _git_noop_config(tmp_path)
    _install_git_sync_results(
        monkeypatch,
        [
            GitSyncResult(head="rev-a", updated=True),
            GitSyncResult(head="rev-b", updated=False),
        ],
    )
    reindex_count = 0
    original_reindex = KnowledgeManager.reindex_all

    async def _track_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        nonlocal reindex_count
        reindex_count += 1
        if reindex_count > 1:
            msg = "unchanged git poll should not reindex"
            raise AssertionError(msg)
        return await original_reindex(self, force_reindex=force_reindex)

    monkeypatch.setattr(KnowledgeManager, "reindex_all", _track_reindex)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state_before_noop = load_published_index_state(published_index_metadata_path(key))
    assert state_before_noop is not None
    assert state_before_noop.published_revision == "rev-a"
    await asyncio.sleep(0.001)
    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    state_after_noop = load_published_index_state(published_index_metadata_path(key))

    assert result.index_published is True
    assert result.indexed_count == 1
    assert state_after_noop is not None
    assert state_after_noop.collection == state_before_noop.collection
    assert state_after_noop.published_revision == "rev-b"
    assert state_after_noop.last_published_at is not None
    assert state_after_noop.last_published_at != state_before_noop.last_published_at
    assert reindex_count == 1


@pytest.mark.asyncio
async def test_git_noop_refresh_skips_corpus_hash_when_revision_is_unchanged(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """An unmoved Git revision proves the corpus is unchanged without reading every file."""
    config, runtime_paths = _git_noop_config(tmp_path)
    _install_git_sync_results(
        monkeypatch,
        [
            GitSyncResult(head="rev-a", updated=True),
            GitSyncResult(head="rev-a", updated=False),
        ],
    )
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    def _unexpected_signature(*_args: object, **_kwargs: object) -> str:
        msg = "an unchanged Git revision must not re-hash the corpus"
        raise AssertionError(msg)

    monkeypatch.setattr(knowledge_manager_module, "_knowledge_source_signature", _unexpected_signature)
    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    assert result.index_published is True
    assert result.availability is KnowledgeAvailability.READY
    assert state is not None
    assert state.published_revision == "rev-a"


@pytest.mark.asyncio
async def test_git_noop_refresh_hashes_corpus_when_revision_moved(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A revision the published index was not built from still needs content verification."""
    config, runtime_paths = _git_noop_config(tmp_path)
    _install_git_sync_results(
        monkeypatch,
        [
            GitSyncResult(head="rev-a", updated=True),
            GitSyncResult(head="rev-b", updated=False),
        ],
    )
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    counter = _install_counting_signature(monkeypatch, knowledge_manager_module)
    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert counter.calls == 1
    assert result.index_published is True


@pytest.mark.asyncio
async def test_git_noop_refresh_hashes_corpus_when_index_predates_revision_tracking(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """An index published without a recorded revision cannot be trusted by revision alone."""
    config, runtime_paths = _git_noop_config(tmp_path)
    _install_git_sync_results(
        monkeypatch,
        [
            GitSyncResult(head="rev-a", updated=True),
            GitSyncResult(head="rev-a", updated=False),
        ],
    )
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    metadata_path = published_index_metadata_path(key)
    published = load_published_index_state(metadata_path)
    assert published is not None
    save_published_index_state(metadata_path, replace(published, published_revision=None))

    counter = _install_counting_signature(monkeypatch, knowledge_manager_module)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert counter.calls == 1


@pytest.mark.asyncio
async def test_reindex_skips_live_corpus_hash_when_revision_is_stable(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A Git revision that held still proves the source did not move while the pass ran."""
    config, runtime_paths = _git_noop_config(tmp_path)
    _install_git_sync_results(
        monkeypatch,
        [GitSyncResult(head="rev-a", updated=True)],
    )
    _install_git_revisions(monkeypatch, ["rev-a"])

    def _unexpected_signature(*_args: object, **_kwargs: object) -> str:
        msg = "a stable Git revision must not re-hash the corpus after indexing"
        raise AssertionError(msg)

    monkeypatch.setattr(knowledge_manager_module, "_knowledge_source_signature", _unexpected_signature)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is True
    assert result.indexed_count == 1


@pytest.mark.asyncio
async def test_reindex_does_not_publish_a_corpus_truncated_by_a_transient_error(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A file lost to a transient stat error must not publish as a complete index.

    Files whose signature scan raises are dropped from the pass's own completeness
    accounting, so the post-pass check is the only thing standing between a
    truncated corpus and publication. Once published at a revision, the unchanged
    fast path would keep republishing it, so the file would never come back.
    """
    config, runtime_paths = _git_noop_config(tmp_path, files=("keep.md", "flaky.md"))
    _install_git_sync_results(
        monkeypatch,
        [GitSyncResult(head="rev-a", updated=True)],
        tracked=("keep.md", "flaky.md"),
    )
    _install_git_revisions(monkeypatch, ["rev-a"])

    original_file_signature = KnowledgeManager._file_signature
    remaining_failures = {"flaky.md": 1}

    def _flaky_signature(self: KnowledgeManager, file_path: Path) -> tuple[int, int, str]:
        if remaining_failures.get(file_path.name):
            remaining_failures[file_path.name] -= 1
            raise OSError(116, "Stale file handle")
        return original_file_signature(self, file_path)

    monkeypatch.setattr(KnowledgeManager, "_file_signature", _flaky_signature)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert result.indexed_count == 2
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.indexed_count == 2


@pytest.mark.asyncio
async def test_reindex_reconciles_when_revision_moves_mid_pass(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A revision that moved while the pass ran must reconcile before publishing."""
    config, runtime_paths = _git_noop_config(tmp_path)
    _install_git_sync_results(
        monkeypatch,
        [GitSyncResult(head="rev-a", updated=True)],
    )
    # Round one starts at rev-a and finds rev-b after indexing; round two is stable.
    _install_git_revisions(monkeypatch, ["rev-a", "rev-b", "rev-b"])

    with capture_logs() as logs:
        result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    events = [entry.get("event") for entry in logs]
    assert "Knowledge source changed during refresh; reconciling candidate" in events
    assert result.index_published is True


@pytest.mark.asyncio
async def test_reindex_hashes_live_corpus_for_non_git_bases(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A local base has no revision to trust, so it still verifies by content."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("local index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    counter = _install_counting_signature(monkeypatch, knowledge_manager_module)
    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert counter.calls >= 1
    assert result.index_published is True


@pytest.mark.asyncio
async def test_git_noop_refresh_ignores_untracked_indexable_file_changes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Git-backed corpora use tracked files only and ignore untracked checkout files.

    The second poll reports a moved revision on purpose. An unmoved revision
    short-circuits change detection entirely, which would let this test pass
    without ever exercising the tracked-only filtering it exists to pin.
    """
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("git tracked index", encoding="utf-8")
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    sync_results = [
        GitSyncResult(head="rev-a", updated=True),
        GitSyncResult(head="rev-b", updated=False),
    ]
    reindex_count = 0
    original_reindex = KnowledgeManager.reindex_all

    async def _sync(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, sync_results.pop(0), "doc.md")

    async def _track_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        nonlocal reindex_count
        reindex_count += 1
        return await original_reindex(self, force_reindex=force_reindex)

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync)
    monkeypatch.setattr(KnowledgeManager, "reindex_all", _track_reindex)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    (docs_path / "untracked.md").write_text("git untracked local corpus", encoding="utf-8")
    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is True
    assert reindex_count == 1
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("git", max_results=5)] == [
        "git tracked index",
    ]


@pytest.mark.asyncio
async def test_git_noop_refresh_rebuilds_when_collection_is_missing(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """An unchanged Git poll must not let Agno auto-create a Chroma collection for a missing index."""
    monkeypatch.setattr("mindroom.knowledge.collections.Knowledge", _AutoCreatingKnowledge)
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("git repaired", encoding="utf-8")
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    sync_results = [
        GitSyncResult(head="rev-a", updated=True),
        GitSyncResult(head="rev-a", updated=False),
    ]
    reindex_count = 0
    original_reindex = KnowledgeManager.reindex_all

    async def _sync(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, sync_results.pop(0), "doc.md")

    async def _track_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        nonlocal reindex_count
        reindex_count += 1
        return await original_reindex(self, force_reindex=force_reindex)

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync)
    monkeypatch.setattr(KnowledgeManager, "reindex_all", _track_reindex)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    assert state is not None
    assert state.collection is not None
    missing_collection = state.collection
    _VectorDb.collections.pop(missing_collection, None)
    knowledge_registry._published_indexes.clear()
    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    repaired_state = load_published_index_state(published_index_metadata_path(key))

    assert result.index_published is True
    assert reindex_count == 2
    assert repaired_state is not None
    assert repaired_state.collection != missing_collection
    assert missing_collection not in _VectorDb.collections
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("git", max_results=5)] == [
        "git repaired",
    ]


@pytest.mark.asyncio
async def test_unchanged_refresh_fails_when_publish_handle_rebuild_returns_none(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """The no-op path must not claim success without a usable published read handle."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("unchanged index", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    monkeypatch.setattr(knowledge_refresh_runner, "publish_knowledge_index_from_state", lambda *_args, **_kwargs: None)

    result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))

    assert result.index_published is False
    assert result.availability is KnowledgeAvailability.REFRESH_FAILED
    assert result.last_error == "Published index collection was missing during unchanged refresh"
    assert state is not None
    assert knowledge_registry.published_index_refresh_state(state) == "refresh_failed"
    assert state.refresh_job == "failed"


@pytest.mark.asyncio
async def test_git_noop_refresh_rebuilds_after_chunking_config_change(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Chunking changes must rebuild even when Git reports no repository updates."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("git chunking old", encoding="utf-8")
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    changed_config = config.model_copy(deep=True)
    changed_config.knowledge_bases["docs"].chunk_size = 1024
    sync_results = [
        GitSyncResult(head="rev-a", updated=True),
        GitSyncResult(head="rev-a", updated=False),
    ]
    reindex_count = 0
    original_reindex = KnowledgeManager.reindex_all

    async def _sync(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, sync_results.pop(0), "doc.md")

    async def _track_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        nonlocal reindex_count
        reindex_count += 1
        return await original_reindex(self, force_reindex=force_reindex)

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync)
    monkeypatch.setattr(KnowledgeManager, "reindex_all", _track_reindex)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("git chunking rebuilt", encoding="utf-8")
    result = await refresh_knowledge_binding("docs", config=changed_config, runtime_paths=runtime_paths)

    assert result.index_published is True
    assert reindex_count == 2
    lookup = get_published_index("docs", config=changed_config, runtime_paths=runtime_paths)
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("git", max_results=5)] == [
        "git chunking rebuilt",
    ]


@pytest.mark.asyncio
async def test_force_git_reindex_bypasses_noop_fast_path(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Explicit reindex should rebuild even when Git reports updated=False."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("git force old", encoding="utf-8")
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    sync_results = [
        GitSyncResult(head="rev-a", updated=True),
        GitSyncResult(head="rev-a", updated=False),
    ]
    reindex_count = 0
    original_reindex = KnowledgeManager.reindex_all

    async def _sync(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, sync_results.pop(0), "doc.md")

    async def _track_reindex(self: KnowledgeManager, *, force_reindex: bool = False) -> RefreshOutcome:
        nonlocal reindex_count
        reindex_count += 1
        return await original_reindex(self, force_reindex=force_reindex)

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync)
    monkeypatch.setattr(KnowledgeManager, "reindex_all", _track_reindex)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    doc.write_text("git force rebuilt", encoding="utf-8")
    result = await refresh_knowledge_binding(
        "docs",
        config=config,
        runtime_paths=runtime_paths,
        force_reindex=True,
    )

    assert result.index_published is True
    assert reindex_count == 2
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("git", max_results=5)] == [
        "git force rebuilt",
    ]


@pytest.mark.asyncio
async def test_git_sync_failure_preserves_last_good_index_and_redacts_error(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A Git sync failure keeps the last-good index available under stale metadata."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("stable git index", encoding="utf-8")
    git_config = KnowledgeGitConfig(
        repo_url="https://ghp_secret:x-oauth-basic@example.com/org/repo.git",
        branch="main",
    )
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)

    async def _sync_success(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, GitSyncResult(head="rev-ok", updated=True), "doc.md")

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_success)
    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    async def _sync_failure(self: GitKnowledgeSource) -> GitSyncResult:
        _ = self
        msg = "fetch failed https://ghp_secret:x-oauth-basic@example.com/org/repo.git"
        raise RuntimeError(msg)

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_failure)
    with pytest.raises(RuntimeError, match="fetch failed"):
        await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert state is not None
    assert knowledge_registry.published_index_refresh_state(state) == "refresh_failed"
    assert state.last_error is not None
    assert "ghp_secret" not in state.last_error
    assert "x-oauth-basic" not in state.last_error
    assert lookup.index is not None
    assert lookup.availability is KnowledgeAvailability.REFRESH_FAILED
    assert [document.content for document in lookup.index.knowledge.search("index", max_results=5)] == [
        "stable git index",
    ]


@pytest.mark.asyncio
async def test_cold_git_sync_failure_records_failed_availability_and_redacted_error(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A first Git failure is observable as refresh_failed instead of initializing."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    git_config = KnowledgeGitConfig(
        repo_url="https://ghp_secret:x-oauth-basic@example.com/org/repo.git",
        branch="main",
    )
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)

    async def _sync_failure(self: GitKnowledgeSource) -> GitSyncResult:
        _ = self
        msg = "clone failed https://ghp_secret:x-oauth-basic@example.com/org/repo.git"
        raise RuntimeError(msg)

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_failure)

    with pytest.raises(RuntimeError, match="clone failed"):
        await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert state is not None
    assert state.status == "indexing"
    assert state.collection is None
    assert state.refresh_job == "failed"
    assert state.last_error is not None
    assert "ghp_secret" not in state.last_error
    assert "x-oauth-basic" not in state.last_error
    assert knowledge_registry.published_index_refresh_state(state) == "refresh_failed"
    assert lookup.index is None
    assert lookup.availability is KnowledgeAvailability.REFRESH_FAILED


@pytest.mark.asyncio
async def test_git_failure_redacts_authorization_headers_from_raised_and_metadata_errors(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Process-local Git Authorization headers should not leak through command failures."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    git_config = KnowledgeGitConfig(
        repo_url="https://example.com/org/repo.git",
        branch="main",
        credentials_service="github_private",
    )
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    get_runtime_shared_credentials_manager(runtime_paths).save_credentials(
        "github_private",
        {"token": "secret-token"},
    )
    encoded = base64.b64encode(b"x-access-token:secret-token").decode("ascii")
    bearer_value = "bearer-value"
    stderr = (
        "fatal: clone failed\n"
        f"GIT_CONFIG_VALUE_0=Authorization: Basic {encoded}\n"
        "decoded credential x-access-token:secret-token\n"
        f"Authorization: Bearer {bearer_value}\n"
    )

    class _FailedGitProcess:
        returncode = 128

        async def communicate(self) -> tuple[bytes, bytes]:
            return b"", stderr.encode()

        def kill(self) -> None:
            return None

        async def wait(self) -> None:
            return None

    async def _fail_git_command(*args: object, **kwargs: object) -> _FailedGitProcess:
        _ = (args, kwargs)
        return _FailedGitProcess()

    monkeypatch.setattr(asyncio, "create_subprocess_exec", _fail_git_command)

    with pytest.raises(RuntimeError) as exc_info:
        await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    key = resolve_published_index_key("docs", config=config, runtime_paths=runtime_paths)
    state = load_published_index_state(published_index_metadata_path(key))
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert state is not None
    assert lookup.state is not None
    error_texts = [str(exc_info.value), state.last_error or "", lookup.state.last_error or ""]

    assert knowledge_registry.published_index_refresh_state(state) == "refresh_failed"
    assert lookup.availability is KnowledgeAvailability.REFRESH_FAILED
    for error_text in error_texts:
        assert "Authorization: Basic ***" in error_text
        assert "Authorization: Bearer ***" in error_text
        assert encoded not in error_text
        assert "x-access-token:secret-token" not in error_text
        assert "secret-token" not in error_text
        assert bearer_value not in error_text


@pytest.mark.asyncio
async def test_git_refresh_marks_duplicate_source_sibling_stale(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A Git update for one base must not leave sibling indexes READY for the old checkout."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    doc = docs_path / "doc.md"
    doc.write_text("shared git old", encoding="utf-8")
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")
    config = _config(
        tmp_path,
        bases={"alpha": docs_path, "beta": docs_path},
        agent_bases=["alpha", "beta"],
        git_configs={"alpha": git_config, "beta": git_config},
    )
    runtime_paths = runtime_paths_for(config)

    async def _sync_updated(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, GitSyncResult(head=f"rev-{self.base_id}", updated=True), "doc.md")

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_updated)

    await refresh_knowledge_binding("alpha", config=config, runtime_paths=runtime_paths)
    await refresh_knowledge_binding("beta", config=config, runtime_paths=runtime_paths)
    beta_lookup = get_published_index("beta", config=config, runtime_paths=runtime_paths)
    assert beta_lookup.index is not None
    assert beta_lookup.availability is KnowledgeAvailability.READY
    assert [document.content for document in beta_lookup.index.knowledge.search("git", max_results=5)] == [
        "shared git old",
    ]

    doc.write_text("shared git new", encoding="utf-8")
    await refresh_knowledge_binding("alpha", config=config, runtime_paths=runtime_paths)
    beta_key = resolve_published_index_key("beta", config=config, runtime_paths=runtime_paths)
    beta_state = load_published_index_state(published_index_metadata_path(beta_key))
    refreshed_beta_lookup = get_published_index("beta", config=config, runtime_paths=runtime_paths)

    assert beta_state is not None
    assert knowledge_registry.published_index_refresh_state(beta_state) == "stale"
    assert refreshed_beta_lookup.index is not None
    assert refreshed_beta_lookup.availability is KnowledgeAvailability.STALE
    assert [document.content for document in refreshed_beta_lookup.index.knowledge.search("git", max_results=5)] == [
        "shared git old",
    ]


@pytest.mark.asyncio
async def test_git_pull_that_changes_one_file_only_reindexes_that_file(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A one-file commit must cost one file's indexing, not the whole checkout's."""
    monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull)
    monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
    remote_work = tmp_path / "remote-work"
    remote_work.mkdir()

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

    await _git(remote_work, "init", "-b", "main")
    await _git(remote_work, "config", "user.email", "tests@example.com")
    await _git(remote_work, "config", "user.name", "MindRoom Tests")
    for index in range(5):
        (remote_work / f"doc{index}.md").write_text(f"original body {index}", encoding="utf-8")
    await _git(remote_work, "add", ".")
    await _git(remote_work, "commit", "-m", "seed")
    remote_bare = tmp_path / "remote.git"
    await asyncio.to_thread(
        subprocess.run,
        ["git", "clone", "--bare", str(remote_work), str(remote_bare)],
        check=True,
        capture_output=True,
        text=True,
    )

    docs_path = tmp_path / "checkout"
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": KnowledgeGitConfig(repo_url=str(remote_bare), branch="main")},
    )
    runtime_paths = runtime_paths_for(config)
    first = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    assert first.indexed_count == 5

    (remote_work / "doc2.md").write_text("rewritten body 2", encoding="utf-8")
    await _git(remote_work, "commit", "-am", "change one file")
    await _git(remote_work, "push", str(remote_bare), "main")

    second = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert second.index_published is True
    assert second.indexed_count == 1, "the whole checkout was reindexed for a one-file commit"
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)
    assert lookup.index is not None
    contents = sorted(document.content for document in lookup.index.knowledge.search("body", max_results=10))
    assert contents == [
        "original body 0",
        "original body 1",
        "original body 3",
        "original body 4",
        "rewritten body 2",
    ]


@pytest.mark.asyncio
async def test_git_worktree_checkout_file_is_detected_for_sync_listing_and_api_status(tmp_path: Path) -> None:
    """Git worktree checkouts use a .git file and must still count as present repositories."""
    remote_work = tmp_path / "remote-work"
    remote_work.mkdir()

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

    await _git(remote_work, "init", "-b", "main")
    await _git(remote_work, "config", "user.email", "tests@example.com")
    await _git(remote_work, "config", "user.name", "MindRoom Tests")
    (remote_work / "doc.md").write_text("worktree checkout content", encoding="utf-8")
    await _git(remote_work, "add", "doc.md")
    await _git(remote_work, "commit", "-m", "main")
    remote_bare = tmp_path / "remote.git"
    await asyncio.to_thread(
        subprocess.run,
        ["git", "clone", "--bare", str(remote_work), str(remote_bare)],
        check=True,
        capture_output=True,
        text=True,
    )
    seed_checkout = tmp_path / "seed-checkout"
    await asyncio.to_thread(
        subprocess.run,
        ["git", "clone", str(remote_bare), str(seed_checkout)],
        check=True,
        capture_output=True,
        text=True,
    )
    docs_path = tmp_path / "worktree-checkout"
    await _git(seed_checkout, "worktree", "add", "--detach", str(docs_path), "HEAD")
    assert (docs_path / ".git").is_file()

    git_config = KnowledgeGitConfig(repo_url=str(remote_bare), branch="main")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    manager = KnowledgeManager("docs", config=config, runtime_paths=runtime_paths)
    resolved_git_config = manager.git_source._git_config()
    assert resolved_git_config is not None

    cloned = await manager.git_source._ensure_repository(resolved_git_config)

    assert cloned is False
    assert git_checkout_present(docs_path)
    assert list_git_tracked_knowledge_files(config, "docs", docs_path) == [docs_path.resolve() / "doc.md"]

    main.initialize_api_app(main.app, runtime_paths)
    _publish_api_config(main.app, config)
    client = TestClient(main.app)
    response = client.get("/api/knowledge/bases/docs/status")

    assert response.status_code == 200
    assert response.json()["git"]["repo_present"] is True


@pytest.mark.asyncio
async def test_candidate_indexing_hashes_content_off_event_loop(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Per-file content hashing should run in a worker thread."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("threaded hash", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    event_loop_thread = get_ident()
    signature_threads: list[int] = []
    original_file_signature = KnowledgeManager._file_signature

    def _record_signature_thread(self: KnowledgeManager, file_path: Path) -> tuple[int, int, str]:
        signature_threads.append(get_ident())
        return original_file_signature(self, file_path)

    monkeypatch.setattr(KnowledgeManager, "_file_signature", _record_signature_thread)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert signature_threads
    assert all(thread_id != event_loop_thread for thread_id in signature_threads)


@pytest.mark.asyncio
async def test_git_updated_stale_registry_mark_uses_async_registry_path(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Refresh runner should mark stale metadata off the event loop."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("git updated", encoding="utf-8")
    git_config = KnowledgeGitConfig(repo_url="https://example.com/org/repo.git", branch="main")
    config = _config(
        tmp_path,
        bases={"docs": docs_path},
        agent_bases=["docs"],
        git_configs={"docs": git_config},
    )
    runtime_paths = runtime_paths_for(config)
    event_loop_thread = get_ident()
    mark_threads: list[int] = []

    async def _sync_updated(self: GitKnowledgeSource) -> GitSyncResult:
        return _record_git_sync(self, GitSyncResult(head="rev-updated", updated=True), "doc.md")

    async def _record_mark_thread(*_args: object, **_kwargs: object) -> tuple[str, ...]:
        mark_threads.append(get_ident())
        return ("docs",)

    monkeypatch.setattr(GitKnowledgeSource, "sync", _sync_updated)
    monkeypatch.setattr(knowledge_refresh_runner, "mark_knowledge_source_changed_async", _record_mark_thread)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert mark_threads == [event_loop_thread]


@pytest.mark.asyncio
async def test_refresh_scheduler_manual_reindex_runs_without_background_queue(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """An awaited manual refresh should bypass duplicate best-effort background schedules."""
    docs_path = tmp_path / "docs"
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    old_config = config.model_copy(deep=True)
    old_config.knowledge_bases["docs"].chunk_size = 1024
    runtime_paths = runtime_paths_for(config)
    scheduler = KnowledgeRefreshScheduler()
    first_started = asyncio.Event()
    release_first = asyncio.Event()
    seen: list[tuple[int, bool]] = []

    async def _fake_refresh(base_id: str, **kwargs: object) -> object:
        assert base_id == "docs"
        refresh_config = kwargs["config"]
        assert isinstance(refresh_config, Config)
        force_reindex = bool(kwargs.get("force_reindex", False))
        seen.append((refresh_config.knowledge_bases["docs"].chunk_size, force_reindex))
        if len(seen) == 1:
            first_started.set()
            await release_first.wait()
        return knowledge_refresh_runner.KnowledgeRefreshResult(
            key=resolve_published_index_key("docs", config=refresh_config, runtime_paths=runtime_paths),
            indexed_count=1,
            index_published=True,
            availability=KnowledgeAvailability.READY,
        )

    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding", _fake_refresh)
    monkeypatch.setattr("mindroom.knowledge.refresh_runner.refresh_knowledge_binding_in_subprocess", _fake_refresh)

    scheduler.schedule_refresh("docs", config=old_config, runtime_paths=runtime_paths)
    await first_started.wait()
    scheduler.schedule_refresh("docs", config=old_config, runtime_paths=runtime_paths)
    manual_task = asyncio.create_task(
        scheduler.refresh_now("docs", config=config, runtime_paths=runtime_paths, force_reindex=True),
    )
    await asyncio.sleep(0)
    release_first.set()
    await manual_task
    for _attempt in range(50):
        if not scheduler._tasks:
            break
        await asyncio.sleep(0)
    else:
        pytest.fail("manual refresh left a stale background refresh running")
    await scheduler.shutdown()

    assert seen == [(1024, False), (5000, True)]


def test_redact_url_credentials_hides_entire_http_userinfo() -> None:
    """Knowledge Git URL redaction must not leak token usernames or URL parameters."""
    assert redact_url_credentials("https://user:password@example.com/repo.git") == "https://***@example.com/repo.git"
    assert redact_url_credentials("https://ghp_secret:x-oauth-basic@example.com/repo.git") == (
        "https://***@example.com/repo.git"
    )
    assert redact_url_credentials("https://username@example.com/repo.git") == "https://***@example.com/repo.git"
    assert redact_url_credentials("ssh://git@example.com/repo.git") == "ssh://***@example.com/repo.git"
    assert redact_url_credentials("ssh://user:pass@example.com/repo.git") == "ssh://***@example.com/repo.git"
    assert redact_url_credentials("git+https://user:pass@example.com/repo.git") == (
        "git+https://***@example.com/repo.git"
    )
    assert redact_url_credentials("https://example.com/repo.git?token=secret#frag-secret") == (
        "https://example.com/repo.git"
    )
    assert (
        redact_url_credentials("https://user:password@example.com/org/repo.git;token=secret?query=secret#frag-secret")
        == "https://***@example.com/org/repo.git"
    )
    assert (
        credential_free_repo_url(
            "https://user:password@example.com/repo.git?token=secret#frag-secret",
        )
        == "https://example.com/repo.git"
    )


def test_credential_free_repo_url_preserves_passwordless_ssh_username() -> None:
    """Passwordless SSH transport usernames are part of the clone identity."""
    assert (
        credential_free_repo_url(
            "ssh://git@example.com/org/repo.git;token=secret?query=secret#frag-secret",
        )
        == "ssh://git@example.com/org/repo.git"
    )


def test_credential_free_repo_url_strips_secret_bearing_userinfo() -> None:
    """Persistent clone URLs must not retain passwords, HTTP userinfo, query strings, or fragments."""
    assert (
        credential_free_repo_url(
            "ssh://git:secret@example.com/org/repo.git;token=secret?query=secret#frag-secret",
        )
        == "ssh://example.com/org/repo.git"
    )
    assert (
        credential_free_repo_url(
            "https://user@example.com/org/repo.git;token=secret?query=secret#frag-secret",
        )
        == "https://example.com/org/repo.git"
    )


def test_git_url_identity_preserves_passwordless_ssh_usernames() -> None:
    """Passwordless SSH usernames are identity, but secret-bearing userinfo is not."""
    assert credential_free_url_identity("ssh://git@example.com/org/repo.git") != credential_free_url_identity(
        "ssh://deploy@example.com/org/repo.git",
    )
    assert credential_free_url_identity("ssh://user:old@example.com/org/repo.git") == credential_free_url_identity(
        "ssh://user:new@example.com/org/repo.git",
    )
    assert credential_free_url_identity(
        "git+https://user:old@example.com/org/repo.git",
    ) == credential_free_url_identity("git+https://user:new@example.com/org/repo.git")
    assert credential_free_url_identity(
        "ssh://user:old@example.com/org/repo.git;token=secret?query=secret#frag-secret",
    ) == credential_free_url_identity("ssh://example.com/org/repo.git")


@pytest.mark.asyncio
async def test_index_file_locked_runs_off_event_loop_thread(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Per-file indexing must run on a worker thread so the asyncio loop stays responsive.

    Knowledge.ainsert in production agno is async by name only: it eventually calls into
    the vector database's synchronous batch upsert (e.g. ChromaDB's Rust _upsert) on the
    running event loop, which blocks Matrix sync, tool calls, and cache writes for the
    full duration of every file's embed+upsert cycle. The manager guards against this by
    using the sync Knowledge.insert API via asyncio.to_thread; this test pins that
    behavior so the regression cannot return silently.
    """
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "doc.md").write_text("hello", encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    main_thread_id = get_ident()
    insert_thread_ids: list[int] = []
    original_insert = _Knowledge.insert

    def _record_insert(self: _Knowledge, **kwargs: object) -> None:
        insert_thread_ids.append(get_ident())
        original_insert(self, **kwargs)

    async def _forbidden_ainsert(self: _Knowledge, **kwargs: object) -> None:
        _ = (self, kwargs)
        msg = (
            "Knowledge.ainsert was called: indexing must use the sync Knowledge.insert "
            "API via asyncio.to_thread to keep the event loop responsive."
        )
        raise AssertionError(msg)

    monkeypatch.setattr(_Knowledge, "insert", _record_insert)
    monkeypatch.setattr(_Knowledge, "ainsert", _forbidden_ainsert)

    await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert insert_thread_ids, "expected at least one insert call during refresh"
    for thread_id in insert_thread_ids:
        assert thread_id != main_thread_id, (
            f"Knowledge.insert ran on the asyncio main thread (id={thread_id}); "
            "it must run on a worker thread via asyncio.to_thread."
        )


@pytest.mark.asyncio
async def test_malformed_json_falls_back_to_text_and_publishes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Malformed JSON remains searchable instead of blocking the whole candidate."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    malformed = '{\n  "claim": "still useful",\n  “broken”: true\n}\n'
    source_path = docs_path / "claim.json"
    source_path.write_text(malformed, encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    monkeypatch.setattr(_Knowledge, "insert", _insert_with_real_reader)
    original_read_text = Path.read_text
    source_reads = 0

    def _count_source_reads(path: Path, *args: object, **kwargs: object) -> str:
        nonlocal source_reads
        if path == source_path:
            source_reads += 1
        return original_read_text(path, *args, **kwargs)

    monkeypatch.setattr(Path, "read_text", _count_source_reads)

    with capture_logs() as logs:
        result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is True
    assert lookup.index is not None
    documents = lookup.index.knowledge.search("still useful", max_results=5)
    assert len(documents) == 1
    assert '"claim": "still useful"' in documents[0].content
    assert "“broken”: true" in documents[0].content
    fallback = [entry for entry in logs if entry["event"] == "Malformed JSON knowledge file; indexing as text"]
    assert [(entry["path"], entry["line"], entry["column"]) for entry in fallback] == [("claim.json", 3, 3)]
    assert source_reads == 1


@pytest.mark.asyncio
async def test_valid_json_keeps_structured_reader(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Valid JSON lists remain separate structured documents."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "claims.json").write_text('[{"claim": "one"}, {"claim": "two"}]', encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)
    monkeypatch.setattr(_Knowledge, "insert", _insert_with_real_reader)

    with capture_logs() as logs:
        result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)
    lookup = get_published_index("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is True
    assert lookup.index is not None
    assert [document.content for document in lookup.index.knowledge.search("claim", max_results=5)] == [
        '{"claim": "one"}',
        '{"claim": "two"}',
    ]
    assert all(entry["event"] != "Malformed JSON knowledge file; indexing as text" for entry in logs)


@pytest.mark.asyncio
async def test_valid_json_does_not_hide_downstream_json_decode_error(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A downstream JSONDecodeError remains a failure when source JSON is valid."""
    docs_path = tmp_path / "docs"
    docs_path.mkdir()
    (docs_path / "claim.json").write_text('{"claim": "valid"}', encoding="utf-8")
    config = _config(tmp_path, bases={"docs": docs_path}, agent_bases=["docs"])
    runtime_paths = runtime_paths_for(config)

    def _fail_after_read(
        self: _Knowledge,
        *,
        path: str,
        metadata: dict[str, object],
        upsert: bool,
        reader: object | None = None,
    ) -> None:
        _ = (self, metadata, upsert)
        selected_reader = cast("Reader", reader)
        selected_reader.read(Path(path), name=Path(path).name)
        message = "downstream response was not JSON"
        raise json.JSONDecodeError(message, "<html>", 0)

    monkeypatch.setattr(_Knowledge, "insert", _fail_after_read)

    with capture_logs() as logs:
        result = await refresh_knowledge_binding("docs", config=config, runtime_paths=runtime_paths)

    assert result.index_published is False
    assert (
        result.last_error
        == "Indexed 0 of 1 managed knowledge files (first error: knowledge indexing failed (JSONDecodeError))"
    )
    assert all(entry["event"] != "Malformed JSON knowledge file; indexing as text" for entry in logs)


#: Repository URLs that must never be written to ``.git/config``. The first four
#: are documented provider credential forms with the scheme mistyped or dropped;
#: ``urlparse`` reports the username as a scheme and finds no authority, so a
#: check that trusted the parse would treat the password as a hostname.
_UNPERSISTABLE_REPO_URLS = [
    pytest.param("oauth2:{secret}@gitlab.com:org/repo.git", id="gitlab-oauth2-form"),
    pytest.param("x-access-token:{secret}@github.com/org/repo.git", id="github-token-form"),
    pytest.param("user:{secret}@github.com:org/repo.git", id="userinfo-without-scheme"),
    pytest.param("user%3A{secret}@github.com:org/repo.git", id="encoded-colon-without-scheme"),
    pytest.param("https:{secret}@host/x", id="scheme-without-slashes"),
    pytest.param("HTTPS:{secret}@host/x", id="uppercase-scheme-without-slashes"),
    pytest.param("http:///git-user:{secret}@example.com/org/repo.git", id="empty-authority"),
    pytest.param("//git-user:{secret}@example.com/org/repo.git", id="protocol-relative"),
    pytest.param("https://user%3A{secret}%40example.com/repo.git", id="percent-encoded-authority"),
    pytest.param("https://user%253A{secret}%2540example.com/repo.git", id="double-encoded-authority"),
    pytest.param("https://host/https://u:{secret}@inner/x", id="nested-url"),
]

#: Remote forms that must keep working. Refusing any of these would break a
#: supported configuration, so the gate has to admit them positively.
_PERSISTABLE_REPO_URLS = [
    ("https://example.com/org/repo.git", "https://example.com/org/repo.git"),
    ("https://user:{secret}@example.com/org/repo.git", "https://example.com/org/repo.git"),
    ("ssh://git@example.com/org/repo.git", "ssh://git@example.com/org/repo.git"),
    ("ssh://git:{secret}@example.com/org/repo.git", "ssh://example.com/org/repo.git"),
    ("git@github.com:org/repo.git", "git@github.com:org/repo.git"),
    ("github.com:org/repo.git", "github.com:org/repo.git"),
    ("git@my_host.com:o/r.git", "git@my_host.com:o/r.git"),
    ("https://[::1]:8443/org/repo.git", "https://[::1]:8443/org/repo.git"),
    ("file:///srv/repos/x.git", "file:///srv/repos/x.git"),
    ("/srv/repos/x.git", "/srv/repos/x.git"),
    ("https://host:8443/a@b", "https://host:8443/a@b"),
    ("https://host/@scope/pkg.git", "https://host/@scope/pkg.git"),
]

#: Netloc codepoints that NFKC-normalise to a URL delimiter. ``urlsplit``
#: rejects these and quotes the offending netloc -- password included -- in the
#: exception, and no redactor can clean that message because it holds no ASCII
#: delimiter to anchor on. Both are reachable from ``repo_url``, an unvalidated
#: string, and neither needs a credential present to make ``urlparse`` raise.
_LOOKALIKE_SEPARATORS = ["\uff20", "\ufe6b", "\uff1a"]


@pytest.mark.parametrize("repo_url_template", _UNPERSISTABLE_REPO_URLS)
def test_unsafe_remote_url_is_refused_rather_than_persisted(repo_url_template: str) -> None:
    """A remote URL that cannot be parsed is refused, not sanitised.

    Writing the checkout's ``origin`` is the one place a credential would land on
    disk and stay there across syncs, so the rule is parse-or-refuse: accept only
    shapes whose authority actually resolves, and reject the rest rather than
    guessing where their userinfo sits.
    """
    secret = "S3CR3T-CANARY"  # noqa: S105
    repo_url = repo_url_template.format(secret=secret)

    with pytest.raises(RuntimeError, match="Refusing to write an unsafe remote URL") as exc_info:
        knowledge_git_source_module._persistable_remote_url(repo_url, "docs")

    assert secret not in str(exc_info.value)


@pytest.mark.parametrize(("repo_url_template", "expected"), _PERSISTABLE_REPO_URLS)
def test_supported_remote_url_forms_are_still_persistable(repo_url_template: str, expected: str) -> None:
    """Parse-or-refuse must not cost any supported remote form."""
    secret = "S3CR3T-CANARY"  # noqa: S105
    persisted = knowledge_git_source_module._persistable_remote_url(repo_url_template.format(secret=secret), "docs")

    assert persisted == expected
    assert secret not in persisted


@pytest.mark.parametrize("separator", _LOOKALIKE_SEPARATORS)
def test_lookalike_separator_refusal_names_no_url(separator: str) -> None:
    """The refusal, and everything chained behind it, must name no URL.

    ``urlsplit``'s message quotes the netloc, and the scheduled refresh
    subprocess logs failures with ``logger.exception``, which prints the whole
    chain -- so the refusal is raised ``from None``.
    """
    secret = "S3CR3T-CANARY"  # noqa: S105
    repo_url = f"https://user:{secret}{separator}example.com/org/repo.git"

    with pytest.raises(RuntimeError) as exc_info:
        knowledge_git_source_module._persistable_remote_url(repo_url, "docs")

    chain = "".join(traceback.format_exception(type(exc_info.value), exc_info.value, exc_info.value.__traceback__))
    assert secret not in chain


@pytest.mark.parametrize("separator", _LOOKALIKE_SEPARATORS)
def test_git_auth_env_refuses_a_lookalike_separator_without_raising(separator: str, tmp_path: Path) -> None:
    """``_git_auth_env`` must be safe on its own, not because of when it is called.

    Every caller reaches it only after ``_persistable_remote_url`` has refused
    such URLs, so this is unreachable in the current ordering. It is pinned
    because that ordering is not a property of the function, and an exception
    escaping here would carry the password into whatever logs it.
    """
    secret = "S3CR3T-CANARY"  # noqa: S105
    repo_url = f"https://user:{secret}{separator}example.com/org/repo.git"

    assert knowledge_git_source_module._git_auth_env(repo_url, None, test_runtime_paths(tmp_path)) is None


@pytest.mark.parametrize("separator", ["\uff20", "\uff1a"])
def test_redacting_an_unparseable_url_does_not_raise(separator: str) -> None:
    """The crash this PR exists to fix, at the redactor itself.

    A netloc holding an NFKC delimiter lookalike makes ``urlparse`` raise. No
    credential is required: recording *any* Git failure whose message contains
    such a URL previously raised while the failure was being recorded, and the
    knowledge API then returned 500 for as long as the error stayed persisted.
    """
    text = f"fatal: unable to access 'https://exa{separator}mple.com/repo.git': failed"

    assert "***" in redact_credentials_in_text(text)


@pytest.mark.parametrize("length", [2047, 2048, 2049, 4096])
def test_long_credential_free_urls_keep_their_diagnostic(length: int) -> None:
    """Redaction must not apply the write path's length policy to diagnostics.

    ``MAX_REDACTABLE_TOKEN_LENGTH`` bounds ``fully_unquoted``, which only the
    ``.git/config`` write gate calls. Bounding the redactor by the same constant
    replaced any URL past 2048 characters with ``***``, so a Git or Git LFS error
    carrying a long endpoint lost its diagnostic entirely -- for a URL that parses
    fine and holds no credential. The expectation here is ``origin/main``'s:
    preserved at every length.
    """
    prefix = "https://host/"
    url = prefix + "a" * (length - len(prefix))
    text = f"fatal: unable to access '{url}': failed"

    assert redact_credentials_in_text(text) == text


def test_redacting_a_non_ascii_basic_token_does_not_raise() -> None:
    """A Basic token that is not decodable must still redact, not blow up.

    ``b64decode`` raises a bare ``ValueError`` for non-ASCII input rather than
    the ``binascii.Error`` a narrower ``except`` would catch, so this pins the
    breadth of that handler: the header is still redacted, and redaction never
    replaces the Git failure it was called to sanitise.
    """
    assert redact_credentials_in_text("Authorization: Basic éééé") == "Authorization: Basic ***"
