From 712493fe567ab12100ee5c18b1514fe918c41a51 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sat, 14 Nov 2026 20:51:35 +0300 Subject: [PATCH 1/3] fix(installer): import llama engine without httpx and wmic --- AGENT_DIARY.md | 17 ++ src/providers/reranker/llama_install.py | 215 ++++++++++++++++++------ src/providers/reranker/llama_runner.py | 29 +++- tests/test_install_import_isolation.py | 152 +++++++++++++++++ tests/test_install_offline_paths.py | 141 ++++++++++++++++ 5 files changed, 492 insertions(+), 62 deletions(-) create mode 100644 tests/test_install_import_isolation.py create mode 100644 tests/test_install_offline_paths.py diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index b971772b..6eb3e7a8 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1960,3 +1960,20 @@ BREACH: MSC-P-19 | date: 2026-10-03 | rule: §12 | guard_at_time: no | detected_ **3. Код установщика в диффах есть.** `gh pr diff 120 | grep auto_yes` → удалённая строка `range(0 if auto_yes …)`, добавленная `range(1 if auto_yes …)` и тест `test_auto_yes_runs_each_step_once`. `gh pr diff 121 | grep "def _"` → `models_enabled`, `_doctor_report`, `_doctor_cli`, `_clean_targets`, `_clean_cli` (имена не `cmd_*`; поиск по `cmd_clean|cmd_doctor` пуст по этой причине). **4. Гипотеза «clean-state до фикса валидировал пустую установку» — опровергнута.** `scripts/verify_clean_state.sh` вообще не вызывает `install.py`: он сам создаёт venv и делает `pip install -e .`; `grep "install.py --yes"` по `.github/` и `scripts/` пуст. Значит баг `--yes` с v3.0 не влиял ни на один прогон CI — ровно поэтому его никто и не поймал; поймал только ручной прогон по README. **Урок:** «CI зелёный» и «инструкция работает» — разные утверждения. Пока установщик не запускали как пользователь (по README, в пустом каталоге), его неинтерактивная ветка была мёртвой, а все гейты оставались зелёными. + +## [2026-11-14] Установщик падал на шаге 7: `httpx`, `wmic` и «тихий» `--with-models` + +**Симптом:** `python install.py` (интерпретатор `C:\Python314\python.exe`, без зависимостей проекта) — `⚠ Step 7/12: llama.cpp engine .. No module named 'httpx'`, дальше `[r]etry / [s]kip / [c]ancel`; итог прогона `11 ok, 1 skipped, 1351 s`. + +**Корни (3, один класс — «шаг установщика зависит от venv, которого ещё нет»):** +1. `step_llama` (`install.py:669`) импортирует `src.providers.reranker.llama_runner`, а тот делал top-level `import httpx`. Шагу нужны только `download_llama_binary`/`is_installed` (stdlib, urllib); httpx нужен сетевым вызовам рантайма. По документации `python install.py` — штатный путь (`docs/en/INSTALL.md:35`), без предварительного `pip install -e .`. +2. `llama_install.py:233` на импорте выполняет `_detect_platform()` → `_detect_cpu()` → `wmic cpu`/`wmic memorychip`; на Windows 11 24H2 `wmic` удалён → `FileNotFoundError` печатался трейсбеком (`logger.warning("exception", exc_info=True)`), RAM/CPU молча деградировали до `8`/`unknown`. +3. (нашло независимое ревью) `download_gguf_model` делал **безусловный** `from huggingface_hub import hf_hub_download` вне `try`, хотя `step_gguf` (шаг 7) идёт **до** `step_models` (шаг 8) — `--with-models` падал тем же `ModuleNotFoundError` на шаге 8. Комментарий «…или urllib» обещал fallback, которого не было. + +**Fix:** httpx — ленивый `_httpx()` (6 мест); RAM — `GlobalMemoryStatusEx` (ctypes), CPU — `ProcessorNameString` из реестра, оба без процессов и с прежним дефолтом 8; Vulkan-проба вынесена в `_detect_vulkan(has_binary)` и **не запускается, если Vulkan-сборки нет** (раньше `vulkaninfo` спавнился на любой машине с `vulkan-1.dll`, а сбой печатался в консоль установки); GGUF — `except ImportError` + рабочий urllib-fallback (sha256, `.part` → atomic replace, `MSCODEBASE_HF_BASE_URL` для тестов); два `logger.warning("exception", exc_info=True)` → осмысленный `debug`. + +**Проверка:** `tests/test_install_import_isolation.py` + `tests/test_install_offline_paths.py` — 18 тестов; на старом коде `5 failed + 2 errors` (импорт) и `5 failed + 2 errors` (офлайн-пути), после фикса 18/18. Guard расширен с `src.*` на **любой локальный пакет**, который тянет `install.py` (стало 4 модуля, включая `adapters.*`). Мутации: top-level `import httpx`, отсутствие сверки sha256, проба без сборки, снятый `except ImportError`, снятый early-return — каждая краснеет. Полный suite: `2534 passed, 10 skipped`, rc=0; `ruff check src/ tests/` чистый. На реальном `C:\Python314\python.exe`: до — `ModuleNotFoundError`, после — `import ok; installed=False version=b11476`, rc=0. `_CPU_INFO`: было `unknown`/`8`, стало `AMD Ryzen 5 5600H`/`15`. + +**Остаток (не в этом PR):** `get_system_summary()` (экспорт без вызывающих) всё ещё зовёт `wmic os get caption` — ошибка ловится `OSError`, трейсбека нет, только неточность отчёта; `.decode()` там без `errors=`. + +**Урок:** «модуль, который импортирует установщик» — часть контракта установки, а не рантайма: top-level импорт третьей стороны переносит venv-зависимость туда, где venv ещё не существует. Ловится только прогоном голым интерпретатором (`-S`), а не обычным pytest; и проверять надо не один шаг, а все шаги, которые идут до установки пакетов. diff --git a/src/providers/reranker/llama_install.py b/src/providers/reranker/llama_install.py index 41f10f35..2071b6f0 100644 --- a/src/providers/reranker/llama_install.py +++ b/src/providers/reranker/llama_install.py @@ -14,7 +14,6 @@ import hashlib import logging import os -import re import shutil import struct import subprocess @@ -94,6 +93,45 @@ def _detect_platform() -> tuple: return "win-cpu-x64", ".exe", ".zip", cpu_info # fallback +def _windows_ram_gb() -> int | None: + """Физическая память через WinAPI. Без `wmic`: на Windows 11 24H2 его нет.""" + try: + import ctypes + + class _MemoryStatusEx(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + + status = _MemoryStatusEx() + status.dwLength = ctypes.sizeof(_MemoryStatusEx) + if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): + return None + return int(status.ullTotalPhys) // (1024**3) + except Exception: + return None + + +def _windows_cpu_name() -> str: + """Имя CPU из реестра. Без `wmic` (удалён в Windows 11 24H2) и без процесса.""" + try: + import winreg + + key = r"HARDWARE\DESCRIPTION\System\CentralProcessor\0" + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key) as handle: + return str(winreg.QueryValueEx(handle, "ProcessorNameString")[0]).strip() + except Exception: + return "" + + def _detect_cpu() -> dict: """Детектит возможности CPU. @@ -125,12 +163,8 @@ def _detect_cpu() -> dict: # RAM try: if sys.platform == "win32": - out = subprocess.check_output( - ["wmic", "memorychip", "get", "capacity"], timeout=5, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ).decode("utf-8", errors="replace") - total_bytes = sum(int(x) for x in re.findall(r"\d+", out) if len(x) > 5) - info["ram_gb"] = total_bytes // (1024**3) + # 8 — тот же консервативный дефолт, что был при отказе wmic + info["ram_gb"] = _windows_ram_gb() or 8 elif sys.platform == "darwin": out = subprocess.check_output(["sysctl", "-n", "hw.memsize"], timeout=5) info["ram_gb"] = int(out.strip()) // (1024**3) @@ -147,14 +181,7 @@ def _detect_cpu() -> dict: # CPU name try: if sys.platform == "win32": - out = subprocess.check_output( - ["wmic", "cpu", "get", "name"], timeout=5, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ).decode("utf-8", errors="replace") - for line in out.splitlines(): - if line.strip() and "Name" not in line: - info["name"] = line.strip() - break + info["name"] = _windows_cpu_name() or info["name"] elif sys.platform == "darwin": out = subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"], timeout=5) info["name"] = out.decode("utf-8", errors="replace").strip() @@ -164,9 +191,8 @@ def _detect_cpu() -> dict: if line.startswith("model name"): info["name"] = line.split(":")[1].strip() break - except Exception as _e: - logger.warning("exception", exc_info=True) - pass + except Exception: + logger.debug("CPU name detection failed", exc_info=True) # CPU features через python cpu_name = info["name"].lower() @@ -224,9 +250,8 @@ def _is_windows_insider() -> bool: if len(parts) >= 3: build = int(parts[2]) return build >= 26000 - except Exception as _e: - logger.warning("exception", exc_info=True) - pass + except Exception: + logger.debug("Windows version probe failed", exc_info=True) return False @@ -364,38 +389,56 @@ def _gguf_path(model_key: str) -> Path: # Измерено E19 (Vega 6 iGPU, 271M embed): CPU быстрее (789 vs 591 tok/s) — # выигрыш 2-5x реален только на дискретных GPU; на shared-memory iGPU # дефолт CPU корректен. Детекция остаётся opt-in через LLAMA_BACKEND=vulkan. -_HAVE_VULKAN = False -if sys.platform == "win32": + + +def _detect_vulkan(has_binary: bool | None = None) -> bool: + """Есть ли Vulkan-сборка и рабочий GPU; при успехе включает LLAMA_BACKEND=vulkan. + + `has_binary=False` — сборки нет, пробу не запускаем вовсе: раньше импорт + модуля звал `vulkaninfo` на любой машине с vulkan-1.dll, включая установщик + (`python install.py`), и печатал предупреждение, если пробы нет. + Недоступная проба — `debug`, а не `warning`: это нормальный случай. + """ + if sys.platform != "win32": + return False + if has_binary is None: + has_binary = _llama_bin_vulkan().exists() + if not has_binary: + return False try: - _vk_dll = Path(os.environ.get("SystemRoot", "C:\\Windows")) / "System32" / "vulkan-1.dll" - if _vk_dll.exists(): - import subprocess as _sp - # §5.16: Popen + communicate вместо run(capture_output=True, text=True) — - # text=True декодирует stdout через locale (cp1251) и крашит reader-поток - # на UTF-8 выводе vulkaninfo (UnicodeDecodeError, мусор в логе). - _vk_proc = _sp.Popen( - ["vulkaninfo", "--summary"], - stdout=_sp.PIPE, - stderr=_sp.DEVNULL, - stdin=_sp.DEVNULL, - creationflags=0x08000000, # CREATE_NO_WINDOW - ) - try: - _vk_bytes, _ = _vk_proc.communicate(timeout=5) - except Exception: - _vk_proc.kill() - _vk_bytes = b"" - _vk_text = _vk_bytes.decode("utf-8", errors="replace") - if "GPU0" in _vk_text and "PHYSICAL_DEVICE_TYPE" in _vk_text: - _vk_bin = _get_vulkan_dir() / f"llama-server{_EXE_SUFFIX}" - if _vk_bin.exists(): - _HAVE_VULKAN = True - os.environ.setdefault("LLAMA_BACKEND", "vulkan") - logger.info("🖥️ Vulkan GPU detected — using GPU for embeddings") - else: - logger.info("🖥️ Vulkan GPU detected, but no Vulkan build — using CPU (msvc)") - except Exception as _e: - logger.warning(f"Vulkan detection failed: {_e}") + vk_dll = Path(os.environ.get("SystemRoot", "C:\\Windows")) / "System32" / "vulkan-1.dll" + if not vk_dll.exists(): + return False + # §5.16: Popen + communicate вместо run(capture_output=True, text=True) — + # text=True декодирует stdout через locale (cp1251) и крашит reader-поток + # на UTF-8 выводе vulkaninfo (UnicodeDecodeError, мусор в логе). + proc = subprocess.Popen( + ["vulkaninfo", "--summary"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + creationflags=0x08000000, # CREATE_NO_WINDOW + ) + try: + raw, _ = proc.communicate(timeout=5) + except Exception: + proc.kill() + raw = b"" + text = raw.decode("utf-8", errors="replace") + if "GPU0" in text and "PHYSICAL_DEVICE_TYPE" in text: + os.environ.setdefault("LLAMA_BACKEND", "vulkan") + logger.info("🖥️ Vulkan GPU detected — using GPU for embeddings") + return True + return False + except FileNotFoundError: + logger.debug("vulkaninfo not found — Vulkan detection skipped") + return False + except Exception as exc: + logger.debug("Vulkan detection failed: %s", exc) + return False + + +_HAVE_VULKAN = _detect_vulkan() # ─── Установка ────────────────────────────────────────────────── @@ -713,6 +756,65 @@ def move(src, dst): return False +# База HF: в тестах подменяется на локальный сервер (MSCODEBASE_HF_BASE_URL), +# чтобы скачивание проверялось без сети. +HF_BASE_URL = os.getenv("MSCODEBASE_HF_BASE_URL", "https://huggingface.co").rstrip("/") + + +def _download_gguf_via_urllib(info: dict, gguf_path: Path, progress_cb=None) -> bool: + """Скачивание GGUF без huggingface_hub: urllib + sha256, атомарная запись. + + Установщик (`python install.py --with-models`) работает интерпретатором + пользователя, где huggingface_hub нет: пакет ставится позже в venv + расширения (шаг 6). Раньше здесь стоял безусловный + `from huggingface_hub import hf_hub_download`, и обещанного комментарием + fallback не существовало — шаг 8 падал ModuleNotFoundError (2026-11-14). + """ + import urllib.request + from urllib.parse import quote + + url = f"{HF_BASE_URL}/{info['repo']}/resolve/main/{quote(info['file'])}" + part = gguf_path.with_suffix(gguf_path.suffix + ".part") + digest = hashlib.sha256() + try: + with urllib.request.urlopen(url, timeout=60) as resp, open(part, "wb") as out: + total = int(resp.headers.get("Content-Length") or 0) + done = 0 + while True: + chunk = resp.read(1024 * 1024) + if not chunk: + break + out.write(chunk) + digest.update(chunk) + done += len(chunk) + if progress_cb and total > 0: + progress_cb( + int(done * 100 / total), + f"{gguf_path.name} ({done / 1048576:.0f}MB/{total / 1048576:.0f}MB)", + ) + expected = str(info.get("sha256") or "").lower() + if expected and digest.hexdigest() != expected: + part.unlink(missing_ok=True) + logger.error( + "❌ SHA256 не совпал для %s: ожидали %s…, получили %s…", + gguf_path.name, expected[:16], digest.hexdigest()[:16], + ) + if progress_cb: + progress_cb(0, "Ошибка: SHA256 не совпал") + return False + part.replace(gguf_path) + logger.info("✅ GGUF модель скачана: %s", gguf_path) + if progress_cb: + progress_cb(100, f"{gguf_path.name} — готов") + return True + except Exception as exc: + part.unlink(missing_ok=True) + logger.error("❌ Ошибка скачивания GGUF %s через urllib: %s", gguf_path.name, exc) + if progress_cb: + progress_cb(0, f"Ошибка: {exc}") + return False + + def download_gguf_model(model_key: str, progress_cb=None) -> bool: """Скачивает GGUF модель из Hugging Face.""" models_dir = _get_models_dir() @@ -725,8 +827,13 @@ def download_gguf_model(model_key: str, progress_cb=None) -> bool: logger.info(f"✅ GGUF модель уже скачана: {gguf_path.name}") return True - # Скачиваем через huggingface_hub (если есть) или urllib - from huggingface_hub import hf_hub_download + # huggingface_hub есть не всегда: установщик работает интерпретатором + # пользователя, пакет ставится позже в venv расширения. Без него — urllib. + try: + from huggingface_hub import hf_hub_download + except ImportError: + logger.info("huggingface_hub недоступен — скачиваю GGUF через urllib") + return _download_gguf_via_urllib(info, gguf_path, progress_cb) logger.info(f"⬇️ Скачиваю GGUF: {info['repo']}/{info['file']}") diff --git a/src/providers/reranker/llama_runner.py b/src/providers/reranker/llama_runner.py index 5327a357..5ad41f1e 100644 --- a/src/providers/reranker/llama_runner.py +++ b/src/providers/reranker/llama_runner.py @@ -50,8 +50,6 @@ from pathlib import Path from typing import Any, Optional -import httpx - from src.providers.reranker.llama_install import ( # noqa: F401 — explicit compat # Конфигурация _IS_INSIDER, @@ -81,6 +79,21 @@ logger = logging.getLogger("mscodebase_server.llama_runner") +def _httpx(): + """httpx нужен только сетевым вызовам рантайма (health/embeddings/rerank). + + Импорт ленивый намеренно: `install.py` импортирует этот модуль в том + интерпретаторе, которым его запустили. На шаге 7 (`llama.cpp engine`) ему + нужны только `download_llama_binary`/`is_installed`, а они на stdlib + (urllib). Top-level `import httpx` валил установку + `No module named 'httpx'` для `python install.py` системным Python + (2026-11-14), хотя зависимости проекта живут в venv расширения. + """ + import httpx + + return httpx + + def _ubatch_arg(model_key: str) -> str: """Роль от model_key (зеркалит флаги --embedding/--reranking в start()).""" if model_key in GGUF_MODELS and model_key != DEFAULT_RERANKER_MODEL: @@ -1208,7 +1221,7 @@ async def _spawn_reranker(self) -> bool: # Ждём /health t0 = time.time() - async with httpx.AsyncClient(timeout=2.0) as client: + async with _httpx().AsyncClient(timeout=2.0) as client: for i in range(self._startup_timeout): await asyncio.sleep(1) try: @@ -1460,7 +1473,7 @@ async def _probe_port(self, port: int) -> bool: try: - async with httpx.AsyncClient(timeout=2.0) as c: + async with _httpx().AsyncClient(timeout=2.0) as c: r = await c.get(f'http://{self._host}:{port}/health') @@ -1478,7 +1491,7 @@ def _probe_port_sync(self, port: int) -> bool: try: - r = httpx.get(f'http://{self._host}:{port}/health', timeout=2.0) + r = _httpx().get(f'http://{self._host}:{port}/health', timeout=2.0) return r.status_code == 200 @@ -1540,7 +1553,7 @@ async def health(self) -> dict: try: - async with httpx.AsyncClient(timeout=2.0) as client: + async with _httpx().AsyncClient(timeout=2.0) as client: r = await client.get(f"{self.base_url}/health") @@ -1581,7 +1594,7 @@ async def embed(self, texts: list[str]) -> Optional[list[list[float]]]: try: - async with httpx.AsyncClient(timeout=30.0) as client: + async with _httpx().AsyncClient(timeout=30.0) as client: r = await client.post( @@ -1621,7 +1634,7 @@ async def rerank(self, query: str, passages: list[str]) -> Optional[list[float]] try: - async with httpx.AsyncClient(timeout=30.0) as client: + async with _httpx().AsyncClient(timeout=30.0) as client: r = await client.post( diff --git a/tests/test_install_import_isolation.py b/tests/test_install_import_isolation.py new file mode 100644 index 00000000..06ad6da7 --- /dev/null +++ b/tests/test_install_import_isolation.py @@ -0,0 +1,152 @@ +"""Шаг 7 установщика обязан работать в голом интерпретаторе. + +`python install.py` запускается тем Python, который есть в PATH (у владельца — +`C:\\Python314\\python.exe`), а зависимости проекта живут в venv расширения +(шаги 5–6 ставят `requirements-lock.txt` туда). Значит модули, которые шаги +установщика импортируют, обязаны импортироваться без third-party пакетов. + +Регресс 2026-11-14: `Step 7/12: llama.cpp engine .. No module named 'httpx'`. +`src/providers/reranker/llama_runner.py` импортировал httpx на верхнем уровне, +хотя скачиванию бинаря httpx не нужен. Второй дефект того же пути импорта: +`llama_install._detect_platform()` на импорте вызывал `wmic` (удалён в +Windows 11 24H2) и печатал трейсбек в консоль установки ещё до шага 7. + +Интерпретатор «как у владельца» моделируется флагом `-S`: site-packages не +подключаются, PYTHONPATH снимается. Контрольный тест доказывает, что `-S` +действительно прячет зависимости (иначе зелёный результат ничего не значит). +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] + + +def bare(code: str) -> subprocess.CompletedProcess: + """Python без site-packages и без PYTHONPATH: «голый» интерпретатор.""" + env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME")} + return subprocess.run( + [sys.executable, "-S", "-c", code], + cwd=str(REPO), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + timeout=120, + ) + + +def test_bare_interpreter_really_hides_project_dependencies(): + """Контроль: если `-S` не прячет пакеты, все остальные тесты бесполезны.""" + proc = bare("import httpx") + assert proc.returncode != 0 + assert "ModuleNotFoundError" in proc.stderr + + +def test_installer_module_imports_without_third_party_packages(): + proc = bare("import install; print(len(install.STEPS))") + assert proc.returncode == 0, proc.stderr[-2000:] + assert int(proc.stdout.strip().splitlines()[-1]) >= 12 + + +def _modules_the_installer_imports() -> list[str]: + """Каждый локальный пакет внутри репозитория, который тянет install.py. + + Не только `src…`: установщик импортирует и `adapters.*` (Zed, сниппеты + клиентов), и регресс там должен ловиться тем же тестом. Третья сторона сюда + не попадает: для неё `REPO/.py` не существует. + """ + import ast + + tree = ast.parse((REPO / "install.py").read_text(encoding="utf-8")) + found = set() + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or node.level or not node.module: + continue + rel = node.module.replace(".", "/") + if (REPO / f"{rel}.py").exists() or (REPO / rel / "__init__.py").exists(): + found.add(node.module) + return sorted(found) + + +@pytest.mark.parametrize("module", _modules_the_installer_imports()) +def test_every_module_the_installer_imports_is_dependency_free(module): + """Общий случай: не только llama_runner, а всё, что тянут шаги установщика.""" + proc = bare(f"import {module}; print('ok')") + assert proc.returncode == 0, proc.stderr[-2000:] + assert proc.stderr.strip() == "", proc.stderr[-2000:] + + +def test_llama_runner_imports_without_httpx(): + """Шаг 7 берёт отсюда download_llama_binary/is_installed — им httpx не нужен.""" + proc = bare( + "from src.providers.reranker.llama_runner import " + "download_llama_binary, is_installed; print('ok')" + ) + assert proc.returncode == 0, proc.stderr[-2000:] + assert "ok" in proc.stdout + + +def test_llama_runner_keeps_its_network_api_when_httpx_is_present(): + """Ленивый импорт не должен ломать рантайм: httpx доступен в venv.""" + proc = subprocess.run( + [ + sys.executable, + "-c", + "import src.providers.reranker.llama_runner as m; " + "assert callable(m._httpx().AsyncClient); print('ok')", + ], + cwd=str(REPO), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr[-2000:] + assert "ok" in proc.stdout + + +def test_importing_the_llama_install_module_is_silent(): + """Ни одной строки в stderr: `wmic`-трейсбек шёл именно отсюда.""" + proc = bare("import src.providers.reranker.llama_install as m; print(len(m.__all__))") + assert proc.returncode == 0, proc.stderr[-2000:] + assert proc.stderr.strip() == "", proc.stderr[-2000:] + + +@pytest.mark.skipif(sys.platform != "win32", reason="WinAPI/реестр — только Windows") +def test_cpu_detection_does_not_shell_out(): + """RAM и имя CPU берутся из WinAPI/реестра, не из `wmic`.""" + import src.providers.reranker.llama_install as llama_install + + def boom(*_args, **_kwargs): + raise AssertionError("CPU detection must not spawn processes") + + original = subprocess.check_output + subprocess.check_output = boom + try: + info = llama_install._detect_cpu() + finally: + subprocess.check_output = original + + assert info["name"].lower() != "unknown", info + assert info["ram_gb"] > 0, info + + +@pytest.mark.skipif(sys.platform != "win32", reason="WinAPI/реестр — только Windows") +def test_cpu_detection_degrades_when_os_probes_fail(monkeypatch): + """Если WinAPI/реестр недоступны — словарь остаётся валидным, без исключения.""" + import src.providers.reranker.llama_install as llama_install + + monkeypatch.setattr(llama_install, "_windows_cpu_name", lambda: "") + monkeypatch.setattr(llama_install, "_windows_ram_gb", lambda: None) + info = llama_install._detect_cpu() + assert info["name"] == "unknown" + assert isinstance(info["cores"], int) and info["cores"] >= 1 diff --git a/tests/test_install_offline_paths.py b/tests/test_install_offline_paths.py new file mode 100644 index 00000000..8f35f892 --- /dev/null +++ b/tests/test_install_offline_paths.py @@ -0,0 +1,141 @@ +"""Офлайн-пути установщика: скачивание модели без huggingface_hub и Vulkan-проба. + +Оба дефекта — один класс с шагом 7 (`No module named 'httpx'`, 2026-11-14): +установщик работает интерпретатором пользователя, где зависимостей проекта нет. +Здесь проверяется, что «нет пакета» не роняет шаг, а проба железа не запускается +на импорте. +""" + +from __future__ import annotations + +import hashlib +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from src.providers.reranker import llama_install + +PAYLOAD = b"GGUF-TEST-PAYLOAD-" * 4096 + + +class _Handler(BaseHTTPRequestHandler): + payload = PAYLOAD + + def do_GET(self): # noqa: N802 — контракт http.server + self.send_response(200) + self.send_header("Content-Length", str(len(self.payload))) + self.end_headers() + self.wfile.write(self.payload) + + def log_message(self, *_args): # тишина в выводе тестов + pass + + +@pytest.fixture() +def local_hf(monkeypatch): + """Локальный «Hugging Face»: HF_BASE_URL указывает на него, сеть не нужна.""" + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + monkeypatch.setattr(llama_install, "HF_BASE_URL", f"http://127.0.0.1:{server.server_port}") + try: + yield server + finally: + server.shutdown() + server.server_close() + + +@pytest.fixture() +def model_env(tmp_path, monkeypatch): + """Модель с известным sha256 и каталог моделей во временной папке.""" + monkeypatch.setattr(llama_install, "_get_models_dir", lambda: tmp_path / "models") + monkeypatch.setitem( + llama_install.GGUF_MODELS, + "test-model", + { + "repo": "org/repo", + "file": "test.gguf", + "size_mb": 1, + "sha256": hashlib.sha256(PAYLOAD).hexdigest(), + }, + ) + return tmp_path / "models" / "test.gguf" + + +def _block_hub(monkeypatch): + """Имитирует установщик в чистом интерпретаторе: пакета нет.""" + monkeypatch.setitem(sys.modules, "huggingface_hub", None) + + +def test_gguf_download_works_without_huggingface_hub(local_hf, model_env, monkeypatch): + _block_hub(monkeypatch) + assert llama_install.download_gguf_model("test-model") is True + assert model_env.read_bytes() == PAYLOAD + assert not model_env.with_suffix(".gguf.part").exists() + + +def test_gguf_download_rejects_a_checksum_mismatch(local_hf, model_env, monkeypatch): + _block_hub(monkeypatch) + monkeypatch.setitem( + llama_install.GGUF_MODELS, "test-model", + {**llama_install.GGUF_MODELS["test-model"], "sha256": "0" * 64}, + ) + assert llama_install.download_gguf_model("test-model") is False + assert not model_env.exists() + assert not model_env.with_suffix(".gguf.part").exists() + + +def test_gguf_download_skips_the_network_when_the_file_exists(model_env, monkeypatch): + model_env.parent.mkdir(parents=True, exist_ok=True) + model_env.write_bytes(b"already-here") + + def no_network(*_a, **_k): + raise AssertionError("download must not start for an existing file") + + monkeypatch.setattr(llama_install, "_download_gguf_via_urllib", no_network) + assert llama_install.download_gguf_model("test-model") is True + assert model_env.read_bytes() == b"already-here" + + +def test_vulkan_probe_is_not_started_without_the_vulkan_build(monkeypatch): + """Нет сборки — нет процесса: импорт установщика не зовёт vulkaninfo.""" + calls: list[object] = [] + monkeypatch.setattr(llama_install.subprocess, "Popen", lambda *a, **k: calls.append(a)) + assert llama_install._detect_vulkan(has_binary=False) is False + assert calls == [] + + +def test_vulkan_probe_is_not_started_on_other_platforms(monkeypatch): + monkeypatch.setattr(llama_install.sys, "platform", "linux") + monkeypatch.setattr(llama_install.subprocess, "Popen", lambda *a, **k: pytest.fail("spawned")) + assert llama_install._detect_vulkan(has_binary=True) is False + + +@pytest.mark.skipif(sys.platform != "win32", reason="WinAPI/реестр — только Windows") +def test_cpu_detection_spawns_no_process_of_any_kind(monkeypatch): + """Ловим любой запуск: Popen/run/check_output/check_call/os.system/os.popen.""" + import os + import subprocess + + def boom(*_a, **_k): + raise AssertionError("CPU detection must not spawn processes") + + for name in ("Popen", "run", "check_output", "check_call", "call"): + monkeypatch.setattr(subprocess, name, boom) + monkeypatch.setattr(os, "system", boom) + monkeypatch.setattr(os, "popen", boom) + + info = llama_install._detect_cpu() + assert info["name"].lower() != "unknown", info + assert info["ram_gb"] > 0, info + + +def test_ram_falls_back_to_the_documented_default(monkeypatch): + monkeypatch.setattr(llama_install.sys, "platform", "win32") + monkeypatch.setattr(llama_install, "_windows_ram_gb", lambda: None) + monkeypatch.setattr(llama_install, "_windows_cpu_name", lambda: "") + info = llama_install._detect_cpu() + assert info["ram_gb"] == 8 + assert info["name"] == "unknown" From b582c9f171f33f95833cd5aa2a053cfc869efa42 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sat, 14 Nov 2026 21:18:39 +0300 Subject: [PATCH 2/3] fix(installer): verify engine against GitHub digest and fail loudly --- AGENT_DIARY.md | 12 +++ install.py | 19 +++- src/providers/reranker/llama_install.py | 106 ++++++++++++++++++---- tests/test_install_import_isolation.py | 15 +++- tests/test_install_integrity_status.py | 114 ++++++++++++++++++++++++ tests/test_install_offline_paths.py | 5 +- 6 files changed, 244 insertions(+), 27 deletions(-) create mode 100644 tests/test_install_integrity_status.py diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 6eb3e7a8..4c620b78 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1977,3 +1977,15 @@ BREACH: MSC-P-19 | date: 2026-10-03 | rule: §12 | guard_at_time: no | detected_ **Остаток (не в этом PR):** `get_system_summary()` (экспорт без вызывающих) всё ещё зовёт `wmic os get caption` — ошибка ловится `OSError`, трейсбека нет, только неточность отчёта; `.decode()` там без `errors=`. **Урок:** «модуль, который импортирует установщик» — часть контракта установки, а не рантайма: top-level импорт третьей стороны переносит venv-зависимость туда, где venv ещё не существует. Ловится только прогоном голым интерпретатором (`-S`), а не обычным pytest; и проверять надо не один шаг, а все шаги, которые идут до установки пакетов. + +## [2026-11-14] Шаг 7 «прошёл», хотя движка нет: неверный пин SHA256 и зелёный отчёт + +**Симптом:** после фикса импорта тот же прогон показал `ERROR … SHA256 не совпадает! Ожидался d5d7…, получен a23e…`, `🚫 Отказ от установки: SHA256 не прошёл проверку` — и при этом `✓ Step 7/12: llama.cpp engine` и итог `12 ok, 0 skipped, 0 failed`. + +**Корни:** (1) Пин `LLAMA_BIN_SHA256["win-cpu-x64"]` неверен с первого коммита (`d84adee`). Архив при этом правильный: GitHub API (`repos/ggml-org/llama.cpp/releases/tags/b11476`) отдаёт для ассета `digest=sha256:a23e…` — ровно то, что скачалось; вторая запись (`win-vulkan-x64`) тоже неверна. (2) `step_llama` на провале добавлял жёлтую строку и возвращался нормально, поэтому `_step` считал шаг ok, а `main()` вообще не возвращал код ошибки — exit code был 0 при любом провале. + +**Fix:** хэши приведены к GitHub `digest` (с комментарием, как перевывести); `_verify_archive_sha256` — пин совпал → ok; пин устарел, но GitHub подтверждает фактический хэш → ok + warning; пина нет → GitHub digest; подтвердить нечем → fail closed, архив удаляется. `step_llama` и `step_gguf` бросают `RuntimeError` вместо зелёной строки; `main() -> int`, `sys.exit(main())`, `return 1 if has_err else 0`. Уборка `.part`/архива — через `_discard` (на Windows занятый файл больше не превращает отказ в `PermissionError`; нашло ревью). + +**Проверка:** живой прогон шага 7 в изолированный каталог (реальная сеть): `download_llama_binary -> True`, `llama-server.exe exists: True`, 23 файла, архив удалён. Реальный ассет 19 441 535 байт, `sha256 = a23e…` = пин, `_verify_archive_sha256 -> True`. Тесты: 28 в трёх файлах (`test_install_import_isolation`, `test_install_offline_paths`, `test_install_integrity_status`); мутации «устаревший пин не сверяется», «нет пина и нет сети — пропускаем», «уборка не ловит OSError», «провал шага снова даёт exit 0», «провал загрузки снова зелёный» — каждая краснеет. Полный suite: `2543 passed, 11 skipped`, rc=0. + +**Урок:** «fail closed» без честного статуса — половина защиты: установщик отказался ставить непроверенный бинарь и в том же выводе объявил успех. Проверять нужно и то, что шаг напечатал, и то, что процесс вернул. diff --git a/install.py b/install.py index 90f06807..4af283a0 100644 --- a/install.py +++ b/install.py @@ -710,7 +710,13 @@ def step_llama(lines, lang): except Exception as e: lines.append((C.YEL, f"⚠ Не удалось скопировать в расширение: {e}")) else: - lines.append((C.YEL, "⚠ Не удалось скачать llama.cpp (будет ONNX)")) + # Раньше здесь был жёлтый «⚠ Не удалось скачать» и шаг считался ok — + # установка рапортовала `0 failed`, оставляя пользователя без движка + # (2026-11-14: неверный пин SHA256 отбивал архив, а итог был зелёным). + raise RuntimeError( + "llama.cpp не установлен: скачивание или проверка SHA256 не прошла " + "(подробности в логе выше)" + ) @_step(7) @@ -766,6 +772,9 @@ def step_gguf(lines, lang): if all_ok: lines.append((C.GRN, "✅ GGUF модели готовы — llama.cpp embedder + reranker")) + else: + # Та же честность, что и в step_llama: не «ok» с жёлтой строкой. + raise RuntimeError("часть GGUF моделей не скачана (см. лог выше)") @_step(8) @@ -1432,7 +1441,7 @@ def _utf8_stdout() -> None: logger.debug("utf-8 stdout wrap failed: %s", e) -def main() -> None: +def main() -> int: argv = sys.argv[1:] _utf8_stdout() mode = _mode_from_argv(argv) @@ -1463,7 +1472,7 @@ def main() -> None: step_copy([], lang) _record_install_meta() print("✅ Sync done. Restart the MCP host (OpenCode session or Zed window) to apply.") - return + return 0 _box( "", @@ -1557,11 +1566,13 @@ def main() -> None: print(f"\n {C.D}Extension: {ZED_EXT_DIR}{C.R}") print(f" {C.D}Python: {PYTHON_EXE}{C.R}") print() + # Ненулевой код при провале шага: раньше exit code всегда был 0 (2026-11-14). + return 1 if has_err else 0 if __name__ == "__main__": try: - main() + sys.exit(main()) except KeyboardInterrupt: print(f"\n {C.RED}Aborted by user.{C.R}") sys.exit(1) diff --git a/src/providers/reranker/llama_install.py b/src/providers/reranker/llama_install.py index 2071b6f0..218b1b57 100644 --- a/src/providers/reranker/llama_install.py +++ b/src/providers/reranker/llama_install.py @@ -12,6 +12,7 @@ from __future__ import annotations import hashlib +import json import logging import os import shutil @@ -607,42 +608,109 @@ def _r2r(rva): # python -c "import hashlib; print(hashlib.sha256(open('llama-...zip','rb').read()).hexdigest())" # Если проверка не проходит — бинарник не будет установлен. # win-cpu-x64 + win-vulkan-x64: посчитаны 2026-07-11 для b9940. +# sha256 опубликованного ассета (источник: GitHub API, поле `digest` у asset). +# 2026-11-14: оба Windows-хэша были неверны с первого коммита — установщик +# скачивал ПРАВИЛЬНЫЙ архив, отбивал его по своему же пину и рапортовал успех. +# Проверка: `gh api repos/ggml-org/llama.cpp/releases/tags/ --jq '.assets[] | {name,digest}'`. +# При пересборке релиза ассет получает новый digest — таблицу надо обновить +# (проверка ниже сверит с GitHub и подскажет актуальное значение). LLAMA_BIN_SHA256 = { - # Windows — хэши проверены - "win-cpu-x64": "d5d7248c7aacaeb0c8f15311acb0f1081874aa7a5de55843702e9e2394a05788", - "win-vulkan-x64": "036835f0adb53b5d48444ab9adb7c29fff898be6fa74f7f9fc15f674ea38b153", - # macOS/Linux — TODO: вычислить и добавить хэши перед установкой на этих платформах + "win-cpu-x64": "a23e548c6b3525c38bcfeceaff919786ae06741857043cb670279b70100e5483", + "win-vulkan-x64": "5c71e7b749697da4a8d46e9ee55486845cbba27c9dfbecb4007f31ba6610d523", + # macOS/Linux — пин не задан: проверка идёт по GitHub digest, без него — fail closed "macos-arm64": None, "macos-x64": None, "ubuntu-x64": None, } +def _discard(path: Path) -> None: + """Удаляет файл, не падая: на Windows он может быть занят другим хэндлом. + + Ревью 2026-11-14: в ветке ошибки `part.unlink()` сам бросал PermissionError + (WinError 32) при параллельной загрузке — наружу уходило не `False`, а + исключение, и `.part` оставался. + """ + try: + path.unlink(missing_ok=True) + except OSError as exc: + logger.debug("не удалось удалить %s: %s", path, exc) + + +def _github_asset_digest(asset_name: str) -> str | None: + """sha256 из GitHub API для ассета текущего релиза; None, если недоступно.""" + url = f"https://api.github.com/repos/ggml-org/llama.cpp/releases/tags/{LLAMA_VERSION}" + try: + request = urllib.request.Request( + url, + headers={"Accept": "application/vnd.github+json", "User-Agent": "mscodebase-installer"}, + ) + with urllib.request.urlopen(request, timeout=15) as response: + payload = json.loads(response.read().decode("utf-8")) + for asset in payload.get("assets", []): + if asset.get("name") == asset_name: + digest = str(asset.get("digest") or "") + if digest.startswith("sha256:"): + return digest.split(":", 1)[1].lower() + return None + except Exception as exc: + logger.debug("GitHub digest недоступен для %s: %s", asset_name, exc) + return None + + def _verify_archive_sha256(archive_path: Path, tag: str) -> bool: """Проверяет SHA256 скачанного архива. - Если хэш для платформы не задан — установка блокируется (fail closed). - Безопасность: неизвестный бинарник НЕ запускаем. + Источник истины: пин `LLAMA_BIN_SHA256[tag]`, если он задан; иначе — `digest` + ассета из GitHub API. Если пин устарел (пересборка релиза), но GitHub + подтверждает фактический хэш — установка продолжается с предупреждением. + Если подтвердить нечем — установка блокируется (fail closed). """ - expected = LLAMA_BIN_SHA256.get(tag) - if not expected: - logger.error( - f"🚫 SHA256 для {tag} не задан в LLAMA_BIN_SHA256. " - f"Установка заблокирована. Вычислите хэш и добавьте в словарь." - ) - return False try: actual = hashlib.sha256(archive_path.read_bytes()).hexdigest() + except Exception as e: + logger.error(f"❌ Ошибка проверки SHA256: {e}") + return False + + expected = LLAMA_BIN_SHA256.get(tag) + if expected: if actual == expected: logger.info(f"✅ SHA256 {tag}: {actual[:16]}... совпадает") return True - logger.error(f"❌ SHA256 не совпадает! Ожидался {expected}, получен {actual[:16]}...") - archive_path.unlink() + published = _github_asset_digest(archive_path.name) + if published and actual == published: + logger.warning( + "⚠️ пин LLAMA_BIN_SHA256[%s] устарел: в таблице %s…, GitHub %s… — " + "обновите словарь (иначе следующая проверка снова пойдёт в сеть)", + tag, expected[:12], actual[:12], + ) + return True + logger.error( + "❌ SHA256 не совпадает! пин %s…, GitHub %s…, получен %s…", + expected[:12], (published or "недоступен")[:12], actual[:12], + ) + _discard(archive_path) return False - except Exception as e: - logger.error(f"❌ Ошибка проверки SHA256: {e}") + + published = _github_asset_digest(archive_path.name) + if published: + if actual == published: + logger.info(f"✅ SHA256 {tag}: подтверждён GitHub digest ({actual[:16]}...)") + return True + logger.error( + "❌ SHA256 не совпадает с GitHub digest! ожидали %s…, получен %s…", + published[:12], actual[:12], + ) + _discard(archive_path) return False + logger.error( + f"🚫 SHA256 для {tag} не задан, и GitHub digest недоступен. " + f"Установка заблокирована. Проверьте сеть или добавьте хэш в LLAMA_BIN_SHA256." + ) + _discard(archive_path) + return False + def download_llama_binary(progress_cb=None) -> bool: """Скачивает и распаковывает llama.cpp бинарник. @@ -794,7 +862,7 @@ def _download_gguf_via_urllib(info: dict, gguf_path: Path, progress_cb=None) -> ) expected = str(info.get("sha256") or "").lower() if expected and digest.hexdigest() != expected: - part.unlink(missing_ok=True) + _discard(part) logger.error( "❌ SHA256 не совпал для %s: ожидали %s…, получили %s…", gguf_path.name, expected[:16], digest.hexdigest()[:16], @@ -808,7 +876,7 @@ def _download_gguf_via_urllib(info: dict, gguf_path: Path, progress_cb=None) -> progress_cb(100, f"{gguf_path.name} — готов") return True except Exception as exc: - part.unlink(missing_ok=True) + _discard(part) logger.error("❌ Ошибка скачивания GGUF %s через urllib: %s", gguf_path.name, exc) if progress_cb: progress_cb(0, f"Ошибка: {exc}") diff --git a/tests/test_install_import_isolation.py b/tests/test_install_import_isolation.py index 06ad6da7..4d18a05d 100644 --- a/tests/test_install_import_isolation.py +++ b/tests/test_install_import_isolation.py @@ -136,8 +136,10 @@ def boom(*_args, **_kwargs): finally: subprocess.check_output = original - assert info["name"].lower() != "unknown", info - assert info["ram_gb"] > 0, info + # На урезанном раннере реестр/WinAPI могут быть недоступны при верном коде; + # деградация покрыта отдельным тестом ниже, здесь важен сам факт отсутствия спавна. + assert isinstance(info.get("name"), str) and info["name"] + assert isinstance(info.get("ram_gb"), int) and info["ram_gb"] >= 0 @pytest.mark.skipif(sys.platform != "win32", reason="WinAPI/реестр — только Windows") @@ -150,3 +152,12 @@ def test_cpu_detection_degrades_when_os_probes_fail(monkeypatch): info = llama_install._detect_cpu() assert info["name"] == "unknown" assert isinstance(info["cores"], int) and info["cores"] >= 1 + +def test_the_guard_covers_the_modules_the_installer_actually_imports(): + """Если список опустеет, parametrize даст skip, а не красный тест.""" + found = set(_modules_the_installer_imports()) + assert { + "adapters.zed.zed_config", + "adapters.clients.snippets", + "src.providers.reranker.llama_runner", + } <= found, found diff --git a/tests/test_install_integrity_status.py b/tests/test_install_integrity_status.py new file mode 100644 index 00000000..fbb25285 --- /dev/null +++ b/tests/test_install_integrity_status.py @@ -0,0 +1,114 @@ +"""Проверка целостности архива и честный статус шага установщика. + +Два дефекта 2026-11-14, найденные живьём: +1. Пин `LLAMA_BIN_SHA256` для Windows был неверен с первого коммита: установщик + скачивал ПРАВИЛЬНЫЙ архив (GitHub API `digest` совпадал с фактическим хэшем), + отбивал его по своему же пину и рапортовал успех. +2. `step_llama` на провале скачивания добавлял жёлтую строку и возвращался + нормально: `_step` считал шаг ok, итог — `0 failed`, exit code 0. +""" + +from __future__ import annotations + +import hashlib +import sys + +import pytest + +import install as installer +from src.providers.reranker import llama_install + +DIGEST = hashlib.sha256(b"archive-bytes").hexdigest() + + +@pytest.fixture() +def archive(tmp_path): + path = tmp_path / "llama-b11476-bin-win-cpu-x64.zip" + path.write_bytes(b"archive-bytes") + return path + + +def test_matching_pin_is_accepted_without_the_network(archive, monkeypatch): + monkeypatch.setitem(llama_install.LLAMA_BIN_SHA256, "win-cpu-x64", DIGEST) + + def no_network(_name): + raise AssertionError("network must not be used when the pin matches") + + monkeypatch.setattr(llama_install, "_github_asset_digest", no_network) + assert llama_install._verify_archive_sha256(archive, "win-cpu-x64") is True + + +def test_stale_pin_is_corrected_by_the_github_digest(archive, monkeypatch): + """Ровно наш случай: пин устарел, GitHub подтверждает фактический хэш.""" + monkeypatch.setitem(llama_install.LLAMA_BIN_SHA256, "win-cpu-x64", "0" * 64) + monkeypatch.setattr(llama_install, "_github_asset_digest", lambda _name: DIGEST) + assert llama_install._verify_archive_sha256(archive, "win-cpu-x64") is True + assert archive.exists() + + +def test_a_real_mismatch_is_rejected_and_the_archive_removed(archive, monkeypatch): + monkeypatch.setitem(llama_install.LLAMA_BIN_SHA256, "win-cpu-x64", "0" * 64) + monkeypatch.setattr(llama_install, "_github_asset_digest", lambda _name: "1" * 64) + assert llama_install._verify_archive_sha256(archive, "win-cpu-x64") is False + assert not archive.exists() + + +def test_no_pin_is_verified_against_the_github_digest(archive, monkeypatch): + monkeypatch.setitem(llama_install.LLAMA_BIN_SHA256, "ubuntu-x64", None) + monkeypatch.setattr(llama_install, "_github_asset_digest", lambda _name: DIGEST) + assert llama_install._verify_archive_sha256(archive, "ubuntu-x64") is True + + +def test_no_pin_and_no_network_fails_closed(archive, monkeypatch): + monkeypatch.setitem(llama_install.LLAMA_BIN_SHA256, "ubuntu-x64", None) + monkeypatch.setattr(llama_install, "_github_asset_digest", lambda _name: None) + assert llama_install._verify_archive_sha256(archive, "ubuntu-x64") is False + assert not archive.exists(), "неподтверждённый архив нельзя оставлять на диске" + + +def test_the_pinned_hashes_are_well_formed(): + for tag, value in llama_install.LLAMA_BIN_SHA256.items(): + if value is None: + continue + assert len(value) == 64 and value == value.lower(), tag + + +@pytest.mark.skipif(sys.platform != "win32", reason="WinError 32 — примитив Windows") +def test_locked_file_is_discarded_without_raising(tmp_path): + """Ревью: уборка .part падала PermissionError и нарушала bool-контракт.""" + path = tmp_path / "busy.part" + path.write_bytes(b"x") + handle = open(path, "rb") + try: + llama_install._discard(path) # не должен бросить + finally: + handle.close() + llama_install._discard(path) + assert not path.exists() + + +def test_step_llama_reports_failure_instead_of_a_green_step(tmp_path, monkeypatch): + from src.providers.reranker import llama_runner + + monkeypatch.setattr(installer, "ZED_EXT_DIR", tmp_path / "ext") + monkeypatch.setattr(llama_runner, "is_installed", lambda: False) + monkeypatch.setattr(llama_runner, "download_llama_binary", lambda progress_cb=None: False) + monkeypatch.setattr(llama_runner, "_get_llama_dir", lambda: tmp_path / "src") + lines: list[tuple[str, str]] = [] + with pytest.raises(RuntimeError): + installer.step_llama(lines, "en") + + +def test_a_failed_step_makes_the_installer_exit_non_zero(monkeypatch, capsys): + """Итог `0 failed` при провале шага стоил пользователю движка.""" + + def boom(_lines, _lang): + raise RuntimeError("engine missing") + + monkeypatch.setattr(installer, "STEPS", [(0, "step_boom", boom)]) + monkeypatch.setattr(installer, "_record_install_meta", lambda: None) + monkeypatch.setattr(installer, "_box", lambda *_a, **_k: None) + monkeypatch.setattr(installer, "_line", lambda *_a, **_k: None) + monkeypatch.setattr(sys, "argv", ["install.py", "--yes"]) + + assert installer.main() == 1 diff --git a/tests/test_install_offline_paths.py b/tests/test_install_offline_paths.py index 8f35f892..a2100deb 100644 --- a/tests/test_install_offline_paths.py +++ b/tests/test_install_offline_paths.py @@ -128,8 +128,9 @@ def boom(*_a, **_k): monkeypatch.setattr(os, "popen", boom) info = llama_install._detect_cpu() - assert info["name"].lower() != "unknown", info - assert info["ram_gb"] > 0, info + # Контракт, а не железо: реестр/WinAPI бывают недоступны при верном коде. + assert isinstance(info, dict) and isinstance(info["name"], str) + assert isinstance(info["ram_gb"], int) and info["ram_gb"] >= 0 def test_ram_falls_back_to_the_documented_default(monkeypatch): From 8ae60f7a6f9a357cb2d7d71901e97eaa0b8c385a Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Sun, 15 Nov 2026 08:31:28 +0300 Subject: [PATCH 3/3] docs(diary): drop conflict markers from the merge of main --- AGENT_DIARY.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index bb1b35d2..1277131c 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1961,7 +1961,6 @@ BREACH: MSC-P-19 | date: 2026-10-03 | rule: §12 | guard_at_time: no | detected_ **4. Гипотеза «clean-state до фикса валидировал пустую установку» — опровергнута.** `scripts/verify_clean_state.sh` вообще не вызывает `install.py`: он сам создаёт venv и делает `pip install -e .`; `grep "install.py --yes"` по `.github/` и `scripts/` пуст. Значит баг `--yes` с v3.0 не влиял ни на один прогон CI — ровно поэтому его никто и не поймал; поймал только ручной прогон по README. **Урок:** «CI зелёный» и «инструкция работает» — разные утверждения. Пока установщик не запускали как пользователь (по README, в пустом каталоге), его неинтерактивная ветка была мёртвой, а все гейты оставались зелёными. -<<<<<<< HEAD ## [2026-11-14] Установщик падал на шаге 7: `httpx`, `wmic` и «тихий» `--with-models` **Симптом:** `python install.py` (интерпретатор `C:\Python314\python.exe`, без зависимостей проекта) — `⚠ Step 7/12: llama.cpp engine .. No module named 'httpx'`, дальше `[r]etry / [s]kip / [c]ancel`; итог прогона `11 ok, 1 skipped, 1351 s`. @@ -1990,11 +1989,9 @@ BREACH: MSC-P-19 | date: 2026-10-03 | rule: §12 | guard_at_time: no | detected_ **Проверка:** живой прогон шага 7 в изолированный каталог (реальная сеть): `download_llama_binary -> True`, `llama-server.exe exists: True`, 23 файла, архив удалён. Реальный ассет 19 441 535 байт, `sha256 = a23e…` = пин, `_verify_archive_sha256 -> True`. Тесты: 28 в трёх файлах (`test_install_import_isolation`, `test_install_offline_paths`, `test_install_integrity_status`); мутации «устаревший пин не сверяется», «нет пина и нет сети — пропускаем», «уборка не ловит OSError», «провал шага снова даёт exit 0», «провал загрузки снова зелёный» — каждая краснеет. Полный suite: `2543 passed, 11 skipped`, rc=0. **Урок:** «fail closed» без честного статуса — половина защиты: установщик отказался ставить непроверенный бинарь и в том же выводе объявил успех. Проверять нужно и то, что шаг напечатал, и то, что процесс вернул. -======= ## [2026-11-13] 5 красных в slow-зоне: тесты читали не то хранилище (fix/freshness-slow-tests) **Симптом:** на `main` (`a6524d6` и `801621d`) `pytest tests/test_freshness_checker.py -m "not benchmark"` → 5 failed / 2 passed; в дефолтном гейте файл не виден, потому что помечен `pytestmark = pytest.mark.slow`. **Корень:** тесты читали `indexer.table` — таблицу LanceDB, — а дефолтное хранилище теперь SQLite (`src/core/indexing/indexer.py:146-154`, `src/config/settings.py::SearchConfig.sqlite_chunks` = `true`). `_index_single_file()` писал в `SQLiteChunkStore`, LanceDB оставался пустым → `set()` в `assert "b.py" in set(df["file_path"])` и `0 == 1` в проверках схемы. Это не регресс Цели 1/2: те же 5 падали на до-мержевом `main`. **Fix (тесты, не код):** чтение через `_file_rows(indexer, columns)` — берёт слой, который реально использует индексатор, и агрегирует чанки SQLite по `file_path`; добавлен `test_store_is_the_default_one`, который пиннит дефолт (`sqlite`), чтобы дрейф был виден. `slow`-маркер снят: файл занимает 4.2 s и обязан быть в гейте — иначе эта поломка снова спрячется. **Проверка:** до фикса 5 failed / 2 passed; после — 8 passed. Мутация: `verify_index_freshness()` принудительно `return 0` → 4 failed / 4 passed (код восстановлен, тесты краснеют как надо). `ruff check src/ tests/` — чисто. **Урок:** «slow» — это не «не важно», а «не проверяется». Если тест читает конкретный бэкенд, он обязан падать громко при смене дефолта, а не жить в исключённой зоне. ->>>>>>> origin/main