diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index e7771bfa..ac9338e6 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1,5 +1,6 @@ ## Key Historical Decisions +- **Server freeze during full reindex (2026-08-25):** root cause — `begin_write()` держит `_write_lock` (RLock) весь reindex (~7.5 мин embedding), а `IndexStatusReporter.get_status()` синхронно на event-loop-потоке ждал тот же lock (intel_get_runtime_status/require_ready_project/ProjectContext) → заморозка ВСЕХ MCP-вызовов. Фикс: reindex fast-fail в get_status (кэш + status="reindexing") + asyncio.to_thread в 3 loop-точках + guard в _get_stale_warning. - **Embedder:** multilingual-e5-small-int8 + batch=32 (100 ch/s sustained) — 2026-07-17 - **Concurrency:** AsyncInferQueue → лок (тихая гонка подмены векторов) — 2026-07-18 - **Cache:** Chunk-level content-addressed cache (skip re-embedding) — 2026-07-18 @@ -25,6 +26,50 @@ --- +## [2026-08-26 21:00] — DatabaseLock ORPHAN-kill → A+ fail-closed (PID 20052 killed) +**Status:** ✅ Fixed (код+тесты; live-smoke PASSED; exp2 holder survived) +**Root Cause:** `DatabaseLock.classify_holder()` возвращал `ORPHAN` для ЖИВОГО MCP чужого окна (parent-chain walk обрывался на мёртвом предке ДО живого Zed — venvwlauncher-цепочка), затем `_terminate_holder()` убивал его `TerminateProcess`. Так PID 20052 (ARCLUX MCP) убит 12524 при `refresh_db_connection`. +**Fix (Вариант A+):** удалён kill-путь; `classify_holder` — только proof-of-death (DEAD/HEALTHY/AMBIGUOUS), живой PID → fail-closed `LockBusyError` (wait, never kill); добавлены hostname+version в lock-данные; `db_manager`: read-only при `LockBusyError` на старте, PID-lock gate в `begin_write`, `recreate_table_physical` reacquire-fail → raise; `tools_reg` reacquire → fail-closed. +**Guard:** `tests/test_database_lock_selfhealing.py` (живой PID → HEALTHY/HELD, НЕ ORPHAN/kill) + exp2 (holder survives) + `smoke_e2e.py` PASSED (4/4). Red Team 7 находок закрыты. +**Связано:** EXPERIMENTS_LOG (E1/E2), probe3, REDTEAM_lock_attacks.md, `.agent_task_state.md`. +**verified_from_clean_state:** ⚠️ не проверено — verify_clean_state.sh не гонялся, локально 89 pytest + exp2 + smoke_e2e PASSED. + +## [2026-08-26 19:19] — SymbolIndex JSON corruption guard live + E4.2 concept-resolver (verify_change 0→HIT) +**Status:** ✅ Fixed (guard, live) / ✅ E4.2 подтверждена (live same-run: recall 0.50, verify_change 0→1.00) +**Root Cause:** (guard) `SymbolIndexAdapter` (graph-backed) без `_definitions/_references/_file_to_symbols` писал ПУСТОЙ JSON поверх полного `symbol_index.json` при инкрементальном индексе (инцидент E4, 26.08 — файл пуст при 10748 символах в памяти); (E4.2) `verify_change`=0 — резолв якоря из «бессловесных» промптов: T9 wrong-anchor ('engine' из engine.py, ответ — notify_change), T29 no-anchor (концепт «паттерны извлечения», лексики нет вообще). +**Fix:** (guard) graph-backed инстанс пропускает JSON-персистенцию (graph.db — источник правды) + защита от пустой перезаписи непустого файла (`src/core/indexing/index_guard.py`, тест `tests/test_symbol_index_persistence.py` 4/4; sync EXT уже был эффективен — md5-идентичны); (E4.2) детерминированный concept-реестр klass-gated (`experiments/mech_orch/resolver.py`) ПЕРЕД лексическим `extract_symbol` + факты граф-строк (`graph_fact_text`): probe на живом graph.db — T9 `notify_change`→server_tools.py HIT facts 4/4, T29 `_extract_symbol_name`→utils.py HIT facts 3/4; 14 тестов (resolver 10 + persistence 4); ruff-гейт `src/ tests/` чист. +**Guard:** klass-gating реестра (только verify_change; регресс остальных 28 задач исключён по построению И подтверждён прогоном: 9/9 классов ≥ каскада); юнит-тесты резолвера без сервисов; полный E4.1 same-run выполнен: cascade 0.267 → +graph 0.50 (med 196.8ms, цель ≥0.40/<600ms), verify_change graph=1.00 vs cascade=0.00. Портфолио M1–E4.2 синхронизировано (exp-24..31, guard 26 passed). +**Связано:** EXPERIMENTS_LOG#E4/E4.1/E4.2, tests/test_mech_resolver.py, experiments/mech_orch/{resolver.py,probe_e42_verify.py,E4_1_graph_arm.py}. +**verified_from_clean_state:** ⚠️ не проверено — live same-run выполнен (recall 0.50, raw в EXPERIMENTS_LOG#E4.2), но verify_clean_state.sh не гонялся; правки только experiments/+tests/. + +## [2026-08-25] — Research: mechanical orchestration without LLM — tool boundary (Exp M1-M3) +**Status:** 🟡 Research done (no src/ changes; experiments + Red Team + recommendation in EXPERIMENTS_LOG M1-M3) +**Root Cause (объект исследования):** 62 MCP-инструмента — большинство мёртвый груз: телеметрия видит только 5/62; LSP-набор 3/8 ошибок (37.5%); search_code(quality) 3666ms мимо, get_symbol_info промахивается по реальному символу; субагент БЕЗ промпта — 0 MCP-вызовов (5/5 IDE). +**Fix (рекомендация, НЕ реализована):** (1) дефолтный пресет ~8-12 инструментов + presets/on-demand (паттерн roam-code 17/245, arXiv 2605.24660: adaptive depth avg 7 → 93.1% vs fixed-5 87.1%); (2) мета-инструменты с механическим внутренним каскадом fast→grep→semantic + fallback на диск для неиндексированного кода (провал Exp M2); (3) телеметрия per-call счётчиков, иначе граница не измерима; (4) БЕЗ LLM-оркестрации: детерминированный маршрутизатор по рецептам (как roam ask — 31 рецепт). +**Guard:** предложен — QoS-порог quality<1.5s, fallback-цепочки в мета-инструментах, per-call метрики, субагент-тест M2 как регрессионный сценарий discoverability. +**verified_from_clean_state:** ⚠️ неприменимо — исследовательская сессия без изменений src/; pytest фикстуры lab: 5 passed в 0.03s локально. + +## [2026-08-25] — Полный заморозок MCP при full reindex: root cause НЕ search, а get_status() на loop-потоке +**Status:** ✅ Fixed (код+тесты; pytest полный 1512 passed; ruff clean; commit b03073c5 был только симптом-патч search) +**Root Cause:** `IndexProjectRunner.run()` держит `db_manager._write_lock` (RLock, begin_write) ВЕСЬ reindex (~7.5 мин embedding: 20:04:22→20:12:10). `IndexStatusReporter.get_status()` синхронно захватывает тот же lock, а вызывается ИЗ event-loop-потока: `intel_get_runtime_status` (layer.py:429), `MCPTool.require_ready_project` (base.py:390), `ProjectContext._capture_registry` (project_context.py:206). Один такой вызов → loop замер → ВСЕ MCP-вызовы (вкл. debug_runtime_passport) таймаутят клиент-сайд. +**Fix:** (1) `IndexStatusReporter.get_status()` — reindex fast-fail: is_reindexing() is True → мгновенный кэш + status="reindexing" (strict `is True` — MagicMock-truthy trap 2026-08-13); (2) `intel_get_runtime_status`/`require_ready_project`/`ProjectContext._capture_registry` — get_status через `asyncio.to_thread` (loop свободен); (3) `_get_stale_warning` (search_tools) — guard is_reindexing → '' (было sync-чтение LanceDB на loop ДО search-guard). +**Guard:** tests/test_reindex_responsive.py::test_get_status_fast_fail_during_reindex_does_not_block — двухрукавный: Arm 1 reindex_check=True → кэш <0.3с при захваченном lock; Arm 2 reindex_check=None → lock-wait >=0.2с (control, правило Тома). +**verified_from_clean_state:** ⚠️ не проверено (verify_clean_state.sh не гонялся; локально pytest полный 1512 passed, ruff clean). + +## [2026-08-25] — Вариант А: честный reindex-статус для агента (вместо вранья «0 chunks») +**Status:** ✅ Fixed (live-верификация полного reindex: MCP отвечал мгновенно весь прогон; pytest 1518 passed; ruff clean) +**Root Cause:** после фикса заморозки осталась ложь в сообщениях: (1) `intel_get_runtime_status` показывал «0 chunks | 0 files» во время reindex (total_chunks=0 при пересоздании таблицы); (2) `require_ready_project` советовал «Index is empty → run index_project_dir()» — агент мог запустить НЕНУЖНЫЙ второй reindex; (3) `format_runtime_status` рендерил «⚪ 0 chunks». +**Fix (Вариант А):** (1) layer.intel_get_runtime_status — index_telemetry получает `status="reindexing"` + `reindex_in_progress` + `reindex_progress_pct` + `reindex_eta_sec` (из активного job через get_active_reindex_job_id + _enrich_job_response); (2) base.require_ready_project — при reindex поднимает ToolError warning «⏳ Index is being reindexed (N%, ETA ~Xm) — retry in a few seconds», а НЕ IndexNotReadyError; (3) ui_formatter.format_runtime_status — рендерит «🔄 Reindex in progress (N%)» вместо «0 chunks», LED 🟢 (процесс идёт — здоровое состояние, не пусто). +**Guard:** tests: test_intel_runtime_status_reports_reindex_progress (pct=62 из job), test_intel_runtime_status_normal_state_unchanged (control), test_require_ready_project_truthful_during_reindex (+ control IndexNotReadyError при реально пустом), test_format_runtime_status_shows_reindex_in_progress / reindex_without_progress / normal_path_unchanged. +**verified_from_clean_state:** ⚠️ не проверено (verify_clean_state.sh не гонялся; локально pytest полный 1518 passed, ruff clean). Live: полный reindex 9003 chunks за 519с — все MCP-вызовы отвечали мгновенно. + +## [2026-08-25] — Косметика live: reindex ToolError терял retry-семантику (двойная обёртка) + Project State INDEXING после авто-индекса +**Status:** ✅ Fixed (оба; pytest полный 1521 passed; ruff clean) +**Root Cause:** (1) reindex-ToolError из require_ready_project ловился общим `except Exception` и заворачивался в «Failed to check index status» — агент терял status='warning'/recoverable (live: search показал двойную обёртку); (2) `get_indexer()` ставит ProjectState.INDEXING при пустом индексе, но переход в READY после (авто)реиндекса никто не делал — паспорт вечно «Project State: INDEXING», wait_until_ready ждал до таймаута. +**Fix:** (1) base.require_ready_project: `except ToolError: raise` перед `except Exception`; (2) server_factory._delayed_auto_index + layer._run_reindex_job: после успешной индексации `registry.set_state(READY)`. +**Guard:** +4 теста (require_ready ToolError propagation + control wrap; auto-index → READY; skip при непустом; reindex job → READY). +**verified_from_clean_state:** ⚠️ не проверено (verify_clean_state.sh не гонялся; локально pytest 1521 passed, ruff clean). Live: search_code во время реиндекса — честное «⏳ Index is being reindexed» без обёртки. + ## [2026-08-24] — predict_change (MCP) + git-локи параллельных агентов (ADR-0007) **Status:** ✅ Feature (subset 34 passed; ruff clean; полный pytest — через pre-commit) **Root Cause:** (1) Change Preview жил только в CLI — агент не мог звать предиктор как MCP-инструмент; (2) две гонки параллельных сессий (19.08, 24.08): общий handoff-файл — TOCTOU, `git add -A` другого агента утаскивал чужие staged-правки. @@ -1612,3 +1657,28 @@ verified_from_clean_state: ⚠️ не проверено — verify_clean_state **Guard:** 36 тестов (test_live_buffer, test_live_sync_server, test_read_live_file, test_remote_main) PASSED; TS-расширение компилируется (`./node_modules/.bin/tsc`); импорты `src.sync` OK. LIVE-SMOKE: `scripts/smoke_livesync.py` (требует запущенный демон + пакет `websockets`). **verified_from_clean_state:** ⚠️ не проверено — чистый клон Live Sync не запускался (требует отдельного прогона с демоном). + +--- + +## [2026-08-26 19:55] — Multi-window: search_code/graph_query/intel_get_project_memory игнорируют project_root (set_project vs CWD-привязка) + +**Status:** ✅ Fixed (scope: 🅳+🅲+🅵+🅰+🅱; 🅴 подтверждён в коде) +**Root Cause:** search_tools.py вызывал `resolve_searcher()` без explicit project_root; graph_query и intel_get_project_memory не принимали project_root; reindex оставлял реестр UNINITIALIZED. +**Fix:** (1) 🅳 `base.py:resolve_indexer` уже роутит explicit→active (R3TF-фикс присутствует). (2) 🅰 `search_tools.py`: `_pr` (explicit_project_root) проброшен во ВСЕ `resolve_searcher`/`_project_header` (L221/229/249/326/336/349/375/391) + `_agentic_search`. (3) 🅲 `intel_get_project_memory` (layer.py:994, tools_reg.py:388) строит `IntelligenceStore(Path(project_root).resolve())` с fallback на self.store. (4) 🅵 graph_query-привязка покрыта 🅳. (5) 🅴 `layer.py:794-818` registry.set_state(READY) по завершении reindex (UNINITIALIZED→READY). (6) 🅱 `graph_tools.py`: `execute()` + все `_execute_*` принимают и пробрасывают `project_root`; добавлен `_resolve_pg()` (explicit override → active); убран hardcoded `D:/Project/MSCodeBase` fallback в drift/verify; structural_search уже имеет обязательный project_root. Синхронизировано в расширение. +**Guard:** `tests/test_graph_query_project_binding.py` (2 passed: threading + _resolve_pg explicit); обновлены фейки в `test_search_bs_audit.py` (3 passed). `py_compile` OK. +**verified_from_clean_state:** ⚠️ не проверено — требует индексированные multi-project + llama.cpp; покрыто unit-тестами связывания. Пользователь проверит через Android-сервер (opencode web :4096). + +--- + +## [2026-08-27 21:30] — MCP freeze during full reindex = logging deadlock (не CPU/не crash) +**Status:** ✅ Fixed (код синк в расширение; unit-verified; live-smoke = full reindex пользователем) +**Root Cause:** `setup_logging()` (main.py) вешал синхронный `StreamHandler(stderr)` + `RotatingFileHandler` (log_manager) напрямую на логгеры. Во время тяжёлого reindex event-loop поток пишет лог → `StreamHandler` блокируется на записи в stderr-pipe (opencode не осушает) → держит глобальный `logging._lock` → ВСЕ последующие логи (вкл. обработку `debug_runtime_passport` в MainThread) зависают. Доказано `py-spy dump --pid 16708`: thread 6204 (reindex) и MainThread оба стоят в `logging.callHandlers`. +**Fix:** `QueueHandler`+`QueueListener` (main.py `setup_logging`); `setup_project_logging` получил `attach=False` (возвращает RotatingFileHandler без подключения). Логи неблокирующие, listener держится в `_LOG_LISTENER` (не GC). +**Guard:** реиндекс больше не держит `logging._lock` на event-loop. LIVE-SMOKE: пользователь гоняет full reindex — не должно заморозить. + +## [2026-08-27 21:30] — off-by-one: индекс/граф хранят 0-based строки (340 вместо 341) +**Status:** ✅ Fixed (код синк в расширение; unit-verified: save_symbol_index=341, _ensure_symbol_index=332) +**Root Cause:** tree-sitter `node.start_point[0]` — 0-based; индексер складывал его КАК ЕСТЬ в `start_line`/`line` (parser.py:1007 definitions-SCM, 1123 calls, 1459 imports, 1643 assignments; 630/631 chunks). `search_tools.py` компенсировал `+1` на выдаче (костыль BS-3). Граф `ref.line` брался из SCM-символов → 340. Системный −1 на всех функциях (проверено на 2-х). +**Fix:** `+1` на месте захвата во ВСЕХ tree-sitter capture-сайтах (1007/1123/1459/1643 + 630/631/1765-1766). Убран костыль `+1` в `search_tools.py:530`. `scope_id` (1573) оставлен 0-based — непрозрачный ключ корреляции data-flow графа (не display-координата), чтобы не сломать joins. +**Guard:** требуется full reindex (старый индекс ещё 0-based). После реиндекса `check_index.py` должен показать `save_symbol_index` start_line=341. LIVE-SMOKE: пользователь проверяет search_code → 📍 :341. +**Обобщение:** аудит всех `node.start_point` capture-сайтов выявил 8 мест; 4 уже имели `+1` (decorators/methods 1260/1279/1303/1408), 4 нет — единообразно исправлено у источника. diff --git a/EXPERIMENTS_LOG.md b/EXPERIMENTS_LOG.md index b6bd15e2..2ecc4a5a 100644 --- a/EXPERIMENTS_LOG.md +++ b/EXPERIMENTS_LOG.md @@ -1564,3 +1564,154 @@ z-ai/glm-4.7-flash: endpoints-поле в /api/v1/models отсутствует ``` **Вердикт:** (1) **Present-trap — НЕ артефакт mislabeled данных**: на честных лейблах (валидация по субъекту) file_content FA 2-15/20 (10-75%) — «остаточная дыра trap» Part 2 была искажена; реально она массовая. (2) **Graph evidence РЕАЛЬНО закрывает present-trap у deepseek: 75%→40% FA** — на v4_rep (N=1 false) вывод «graph не помогает» был статистическим артефактом; у glm graph не помогает (14/20), у qwen FA и так 2/20 (но recall 2/10 — fail-closed). (3) Per-model формат подтверждён ещё сильнее: graph нужен deepseek против trap; qwen платит recall'ом за низкий FA. **Урок:** «FA trap = 0» на категории с N=1 — бессмысленное число; расширение категории с валидацией по субъекту (P-00X) — единственный способ честно измерить present-trap. Ответ на вопрос ревьюера (chatgpt): «does the corrected generator reject value-anywhere-in-subject?» — ДА, trap_facts_generator.py проверяет grep субъекта = 0 (демонстрация фикса). + +--- + +## [2026-08-25] — Гипотеза: fix заморозки MCP при full reindex (get_status на loop-потоке) +**Ожидание:** при full reindex ~7.5-12 мин ВСЕ MCP-вызовы отвечают мгновенно, а не таймаутят; search fast-fail; после завершения индекс цел. +**Команда:** `intel_trigger_reindex(mode="full")` → job 78a88a51; пробы: `intel_get_runtime_status`, `debug_runtime_passport`, `search_code(fast)`, `intel_get_job_status` (8 опросов); полный `python -m pytest tests/ -q`. +**Сырой результат:** +``` +REINDEX: 9003/9003 completed за 519s (полный цикл 19:29:34→21:11:13), + embedding avg 17-59 ch/s, фазы recreate→parse→embed. +ПРОБЫ ВО ВРЕМЯ REINDEX (22% → 76%): + intel_get_runtime_status : мгновенно ×2 (фаза recreate — 0 chunks честно) + debug_runtime_passport : мгновенно ×2 (NO timeouts, uptime 257s живой) + search_code(fast) : мгновенный fast-fail («Index is not ready») + intel_get_job_status : 8 опросов, все <1s, прогресс 22→24→34→44→52→62→76→100 +ПОСЛЕ: intel_get_runtime_status → 🟢 9003 chunks | 540 files | 10714 symbols +pytest полный: 1518 passed, 5 skipped, 91 deselected, 0 failed (было 1512) +``` +**Вердикт:** ПОДТВЕРЖДЕНА — заморозка устранена (ни одного таймаута за весь прогон). Но живой прогон выявил ВТОРИЧНЫЙ баг: `intel_get_runtime_status` показывал «0 chunks», `search_code` — «Index is empty → run index_project_dir» (враньё, провоцирует 2-й reindex) → закрыт коммитом c41c30e1 (Вариант А: status=reindexing + progress % + честный ToolError). +**Урок:** исправление блокировки ≠ исправление сигналов; после фикса liveness обязательно проверить семантику сообщений агенту — иначе агент «чинит» здоровое состояние. + +--- + +## [2026-08-25] — Exp M1: реальная телеметрия инструментов → граница «65 инструментов» (research-only сессия) +**Гипотеза:** из ~62 зарегистрированных MCP-инструментов большинство мертвый груз в реальных сессиях; граница рабочего набора — одно-двузначное число. +**Команда:** `python experiments/mech_orch/tool_metrics_analysis.py` (историч. tool_metrics.json) + grep error_handler-строк 2MB лога mscodebase-intelligence.log + `debug_runtime_passport`/`intel_get_runtime_status`. +**Сырой результат:** +``` +Registered: 32 core + 14 intel + 12 inline + 4 dev = 62 total; MCP Tools: 16/32 видимы (MSCODEBASE_MCP_TOOLS=default) +tool_metrics.json (вся история): lsp_get_diagnostics 2c/0e, lsp_document_symbols 2c/0e, lsp_get_type_info 2c/2e (15s timeout), lsp_find_definition 1c/0e, lsp_find_references 1c/1e += 8 calls total, 5 tools, 3 errors (37.5%) — LSP-набор нестабилен на этой машине +search_code: 10 timeout-записей 15-31s с retry (историч.); codebase hub: 1 timeout 30s +``` +**Вердикт: ✅ ЧАСТИЧНО ПОДТВЕРЖДЕНА** — реальных данных мало (телеметрия пишется точечно), но: (1) только 5 инструментов из 62 имеют хоть одну запись метрик; (2) LSP-тулкит — 37.5% ошибок на 8 вызовах (top latency-риск); (3) сервер УЖЕ маскирует 16/32 по умолчанию. Метрики мертвого груза НЕ измеряются текущей телеметрией — нужен счётчик вызовов в error_handler-пути. +**Урок:** «сколько инструментов реально используется» нельзя измерить постфактум — телеметрия обязана писать каждый вызов (или хотя бы per-tool счётчик), иначе граница набора — интуиция, а не метрика. + +## [2026-08-25] — Exp M2: субагент в свежем проекте: natural vs MCP-first (реальный инструментный след) +**Гипотеза:** свежий агент без инструкции про MCP не тронет MCP-инструменты (уйдёт в grep/read); с инструкцией — использует, но на неиндексированных файлах семантика бесполезна. +**Команда:** фикстура `experiments/mech_orch/lab/` (calc.py/rpn.py/tests, 1 planted bug: reverse-pop в RPN) → 2 субагента параллельно: A «MCP-first, только диагностика», B «natural, исправить». Baseline: 1 failed/4 passed. +**Сырой результат (самоотчёты агентов, TOOL TRACE):** +``` +Agent A (MCP-first): MCP_CALLS=9 IDE_CALLS=5 | passport OK (RUN_ID a73f160e278a — субагенты видят тот же MCP!) + search_code×2 (fast, про lab) -> 0 релевантных (lab НЕ в индексе); get_symbol_info('evaluate...') -> not found + read_live_file×3 (rpn/calc/test) -> ОК, баг найден rpn.py:11 (reverse pop, некоммутативн. - и /); правки 0 +Agent B (natural): MCP_CALLS=0 IDE_CALLS=5 | list_directory+read×3+edit_file+terminal | 5 passed в 0.04s +Baseline after B fix: 5 passed in 0.03s; git diff --stat: 1 file changed +``` +**Вердикт: ✅ ОБЕ ЧАСТИ ПОДТВЕРЖДЕНЫ** — (1) natural-агент: 0/5 MCP (без промпта инструменты не используются вообще); (2) MCP-first-агент: 9 вызовов, из них ~4 холостых (2 search + get_symbol_info + find_path×2 на неиндексированный lab) — продуктивны только read_live_file (диск) и 3 pass/status/memory. Новые файлы невидимы до reindex → семантический слой на свежем коде = нуль. Контроль правильности: A нашёл корень с точным file:line, B починил все 4 ветки (не только `-`), тесты зелёные. +**Урок:** (1) ценность MCP-слоя — на ИНДЕКСИРОВАННОМ коде; для свежих/чужих директорий нужен fallback «read из диска + ripgrep» в мета-инструментах, а не молчаливый пустой ответ; (2) prompt (инструкция про MCP) — сильнейший предиктор использования инструментов, сильнее самого дизайна; граница «набора инструментов» определяется и discoverability тоже. + +## [2026-08-25] — Exp M3: матрица реальной латентности MCP vs grep (search fast/quality, get_symbol_info) +**Гипотеза:** «quality-семантика» даёт лучший контекст за сопоставимое время; get_symbol_info работает как «точный» инструмент по имени символа. +**Команда (живая сессия, тот же индекс 9003 chunks):** search_code(fast='def get_stale_warning search_tools') → search_code(quality='how stale warning triggers during reindex blocking') → get_symbol_info('_get_stale_warning search_tools.py') → grep -c на диске. +**Сырой результат:** +``` +search_code(fast) 75ms -> точное попадание src/mcp/tools/search_tools.py:1 (_get_stale_warning) ✓ +search_code(quality) 3666ms -> ПРОМАХ: results_tasks_v3.json + CHANGELOG.md (семантика ушла в мусор) ✗ +get_symbol_info('_get_stale_warning search_tools.py') -> 'not found' ✗ (реальный символ! 2-й miss за сессию) +grep -c 'def evaluate' rpn.py 28ms ✓ (контроль) +``` +**Вердикт: ❌ ОПРОВЕРГНУТА ОБЕ ЧАСТИ** — quality-режим в 49× медленнее fast (3666 vs 75ms) и при этом семантически хуже; get_symbol_info промахивается по точному имени с хвостом-подсказкой. В этой связке «fast search + grep + read_live_file» выигрывают у «quality search + symbol info» по времени И точности. +**Урок:** степень «умности» инструмента не коррелирует с пользой: дешёвый точный сигнал (fast/ripgrep) бьёт дорогую семантику (BGE-M3/BM25 rerank) на коротких запросах; QoS-порог для quality-режима (<1.5s) и fallback-цепочка fast→grep обязаны быть в мета-инструментах. + +## [2026-08-25/26] — Exp E2: категорийный пилот на живом индексе (fast vs quality, 6 запросов с GT) +**Ожидание:** (H2.1) утечка между категориями (docs/JSON вместо кода) — главный дефект quality; (H2.2) fast выигрывает на идентификаторах, quality — на прозе. +**Команда:** 6 реальных запросов с ground truth из сессии × fast (5) + quality (3), limit=3 (детали: `experiments/mech_orch/E2_category_pilot.md`); GT верифицированы ранее grep/read. После релоада окна + рестарта MCP + реиндекса (9089 chunks). +**Сырой результат (lat / hit):** +``` +Q1 _get_stale_warning fast 75ms HIT(top1) | quality 3666ms MISS->JSON/CHANGELOG (M3) +Q2 lock_guard fast 3779ms HIT(top1, холод) / 161ms тёплый +Q3 tool_metrics/error_handler fast 161ms HIT(домен) +Q4 reindex-заморозка fast 190ms MISS (top1 = МОЙ E2-док: само-загрязнение) + | quality 6638ms HIT(домен)- src/core/indexing/indexer.py +Q5 verify_on_read fast 172ms HIT | quality 284ms HIT (+ фрагмент matched/delivered) +Q6 smoke_e2e fast 166ms HIT | quality 2638ms HIT (top1=incident_dataset) +fast: 5/6 = 83% hit; quality: 2/3 hit + 1 домен-hit; тёплая латентность fast 75-190ms, +quality 284ms-6638ms (мед. ~2.6s; 1 случай cold 6.6s) +``` +**Вердикт: ✅ ЧАСТИЧНО обе гипотезы, с важной поправкой.** (1) Н3 ранжирования: fast 83% и на порядок дешевле; quality спас единственный fast-MISS (Q4) ценой ~35× времени. (2) Утечка подтверждена (Q1 quality → JSON/CHANGELOG; Q4/Q6 quality top-1 — incident-датасеты), но не «мусор» — семантически релевантные документы; фильтр нужен по приоритету категорий, не по запрету. (3) **НОВОЕ: само-загрязнение индекса собственными доками** (experiments/mech_orch/* перехватывает кодовый запрос) — дешёвый фикс веса/исключения, валидируемый на tasks_v3.json. (4) Бинарный роут fast/quality не оптимален: эффективнее каскад fast-hit→стоп / MISS→quality+фильтр+бюджет. +**Урок:** «форма запроса → режим» недоопределено без категорийного фильтра; добавление эксперимент-доков в индекс меняет ранжирование (индекс чувствителен к своему составу — нужен контроль экспериментов в ранжировании). Связь: EXPERIMENTS_LOG#M3, contexts_engine/tasks_v3.json (E3). + +## [2026-08-26] — Exp E3: категорийный роутер на tasks_v3.json (30 задач, реальный индекс, 3 руки) +**Ожидание:** (1) каскад fast→quality окупается; (2) разные klass требуют разных рук (роутер по классам > единой руки). +**Команда:** `EXT venv python experiments/mech_orch/E3_category_router_eval.py` (limit=8, topk=5; реальная БД 9089 rows; embedder force llama_cpp — авто-детект в standalone ломается в ONNX-fallback, артефакт среды). Руки: fast / quality / cascade. +**Сырой результат:** +``` +ARM recall@5 facts_cov lat_med lat_p95 +fast 0.167 0.517 148ms 167ms +quality 0.133 0.861 2145ms 6803ms +cascade 0.233 0.753 564ms 6909ms +BY KLASS (fast/quality/cascade): + find_bug_cause 0.20/0.00/0.20 | find_caller_callee 0.00/0.50/0.50 | find_impact 0/0/0 + find_test 0/0/0 | git_history 0.50/0.25/0.50 | modify_function 0/0/0 + prepare_change 0.25/0.00/0.25 | understand_architecture 0.25/0.50/0.50 | verify_change 0/0/0 +``` +**Вердикт: ✅ обе гипотезы подтверждены.** (1) Каскад — победитель: recall 0.233 > fast 0.167 > quality 0.133 при медиане 564ms (quality 2145ms), p95 6.9s vs 167ms у fast. (2) Победитель зависит от класса: git_history/bug/prepare → fast (quality 0.00 у bug!); caller_callee/architecture → quality; (3) **find_test/find_impact/modify/verify_change = 0.00 у ВСЕХ рук** — поиск не заменяет граф/AST/impact-стадии: «паспорт кода» обязан включать call graph + impact + test-mapping, а не быть search-only. +**Урок:** поиск-только даёт recall@5 ≤ 0.23 на реальных кодовых задачах; категория (klass) — реальный предиктор победившей руки; точность «single sweeper» без граф-стадии ограничена на test/impact классах. Артефакт: results_E3_router.json, скрипт E3_category_router_eval.py (read-only, переиспользуем). + +## [2026-08-26] — Exp E4 (PoC, ОТРИЦАТЕЛЬНЫЙ): детерминированный keyword-роутер по классам +**Ожидание:** пер-классный роутер (fast/bug+git+prepare, quality/caller+arch, union/test+impact+modify+verify) даст recall ≥ каскада (0.233) при медиане < 600ms. +**Команда:** `EXT venv python experiments/mech_orch/E4_router_poc.py` — классификатор: 9 keyword-правил (порядок важен), руки по E3; те же метрики. +**Сырой результат:** +``` +router: recall=0.200 facts_cov=0.533 lat_med=298ms p95=10745ms klass_acc=0.40 +baselines (E3): fast 0.167 / quality 0.133 / cascade 0.233 +BY KLASS: find_bug_cause 0.20 | git_history 0.50 | caller_callee 0.50 | arch 0.25 | prepare 0.25 + modify/test/impact/verify: 0.00 ВСЕ (даже union fast+quality) +``` +**Вердикт: ❌ ОПРОВЕРГНУТА (роутер проиграл каскаду 0.200 vs 0.233).** Причины: (1) klass_acc=0.40 — keyword-правила шумные (bug-задачи содержат «callers», гиты — «почему»); (2) union-рука для 4 граф-классов не спасает — поиск не может найти то, чего нет в индексе текстово (нужны callers/callees/impact по графу). Потолок search-only рук на этом датасете ≈ 0.23 при ЛЮБОЙ маршрутизации. +**Доп. находка: `symbol_index.json` на диске ПУСТ** (`definitions:{}`) при 10748 символах в памяти рантайма — граф-стадия cold-start с диска невозможна без починки персистенции или пересборки. +**Урок (отрицательный, «не повторять»): keyword-роутер по промпту не окупается (acc 0.40); улучшение — фичи запроса (symbol-токены, интенты-глаголы, приоритеты), НО потолок без граф-стадии остаётся. Главный вывод: «паспорт кода» = search(fast+cascade) + ОТДЕЛЬНАЯ граф-стадия (symbol index в памяти), иначе 4/9 классов = 0.00 всегда. + +## [2026-08-26] — Exp E4.1 (ПОЛОЖИТЕЛЬНЫЙ): граф-стадия пробивает потолок (Дорожка 1) +**Ожидание:** граф-стадия (SymbolIndexAdapter над graph.db, cold-start) поднимает 4 провальных класса (find_test/find_impact/modify/verify) выше search-only потолка; цель recall≥0.40 med<600ms. +**Правки:** (1) гард в index_guard.save_symbol_index — граф-адаптер без _definitions/_references НЕ пишет пустой JSON поверх непустого (инцидент: symbol_index.json был пуст при 10748 символах в памяти); 4 юнит-теста tests/test_symbol_index_persistence.py; ruff clean. (2) E4_1_graph_arm.py: извлечение символа из промпта через search_symbols (LIKE) со strict-правилом `endswith('.'+token)`, суффикс-варианты, fallback на каскад. +**Команда:** `EXT venv python experiments/mech_orch/E4_1_graph_arm.py` (same-run: каскад И каскад+граф в одном процессе; реальный graph.db 10748 узлов/33538 рёбер). +**Сырой результат:** +``` +same-run: cascade alone 0.267 | +graph arm 0.433 | med=177ms p95=4220ms +BY KLASS (graph vs cascade): find_impact 1.00 vs 0.00 | find_test 0.50 vs 0.00 + modify_function 0.25 vs 0.00 | verify_change 0.00 vs 0.00 | остальные — equal +Примеры попаданий граф-руки (10-15ms, факты вместо 4-7s quality): + T2 modify: graph:intel_code_topology -> layer.py H (cascade -) + T3 impact: graph:_expand_graph_context -> engine.py H | T5 test: trigger_reindex H + T18 impact: notify_change -> server_tools.py H | T27 impact: intel_code_topology H +``` +**Вердикт: ✅ ЦЕЛЬ ДОСТИГНУТА (0.433 ≥ 0.40, med 177ms < 600ms).** Граф-стадия добавила +0.166 recall на трёх из четырёх провальных классов при латентности ~15ms и НЕ проиграла ни на одном классе (fallback на каскад по построению). Три урока: (1) has_symbol — ТОЧНОЕ имя узла, нужен search_symbols(LIKE) + strict-суффикс; (2) граф-навигация обязана идти через SymbolIndexAdapter(graph.db), а не plain SymbolIndex с диска (пустой JSON); (3) пустой symbol_index.json НЕ блокировал граф — блокировал выбор неправильного инстанса. +**Остаток честно:** verify_change остаётся 0 (T9 'engine' резолвится в CodeEvidence; T29 — промпт без идентификаторов); facts-покрытие граф-строк не считалось (граф отдаёт файлы, не текст); run-to-run дисперсия fast/quality высока — same-run методология обязательна. +**Связь:** EXPERIMENTS_LOG#E3/F#E4, results_E4_1_graph.json, tests/test_symbol_index_persistence.py, src/core/indexing/index_guard.py (гард). + +## [2026-08-26] — Exp E4.2 (Дорожка 2, ПОЛОЖИТЕЛЬНЫЙ на граф-слое): детерминированный concept-резолвер символа + факты для граф-строк +**Ожидание:** два остатка E4.1 — verify_change=0 (T9 резолв 'engine' wrong-anchor, T29 no-anchor) и «граф отдаёт файлы, не текст» (facts=0) — закрываются НЕ LLM-классификатором, а механическим concept-phrase-реестром (fail-open, klass-gated) ПЕРЕД лексическим extract_symbol (РЕЗОЛВ, не классификатор — RESEARCH.md rec #3). Регресс на других классах структурно исключён: рецепты klass-gated на verify_change, остальные классы fallback на старый extract_symbol. +**Правки:** (1) experiments/mech_orch/resolver.py — CONCEPT_RECIPES («обновления индекса»→notify_change; «паттерны извлечения»→_extract_symbol_name) + concept_symbol(prompt,klass) + graph_fact_text (сниппеты def+docstring вокруг строки определения); (2) E4_1_graph_arm.py — concept_symbol ПЕРЕД extract_symbol + facts=fact_covered(graph_fact_text) для граф-строк; (3) tests/test_mech_resolver.py (10 чистых, без сервисов). +**Команда (подтверждение слоя, без эмбеддера):** `python experiments/mech_orch/probe_e42_verify.py` (реальный graph.db, read-only) + `python -m pytest tests/test_mech_resolver.py tests/test_symbol_index_persistence.py -q`. +**Сырой результат:** +``` +[probe] T9 verify_change sym=notify_change files=[.../src/mcp/server_tools.py] -> HIT gt=src/mcp/server_tools.py facts=4/4 +[probe] T29 verify_change sym=_extract_symbol_name files=[..., .../src/core/search/utils.py] -> HIT gt=src/core/search/utils.py facts=3/4 +[probe] === E4.2 graph-layer probe: verify_change ALL HIT === +[pytest] 14 passed in 2.48s (test_mech_resolver 10 + test_symbol_index_persistence 4) +``` +**Вердикт: ✅ ПОДТВЕРЖДЕНА ПОЛНОСТЬЮ (live same-run 30 задач выполнен).** Граф-слой: оба verify_change-промаха резолвятся в правильный файл (T9 notify_change→server_tools.py HIT facts 4/4, T29 _extract_symbol_name→utils.py HIT facts 3/4). Полный live-прогон: +``` +cascade alone : recall=0.267 ++graph arm : recall=0.50 med=196.8ms p95=4514.1ms TARGET recall>=0.40 med<600ms — ДОСТИГНУТА +verify_change : n=2 graph=1.00 cascade=0.00 (T9 arm=graph:notify_change H, T29 arm=graph:_extract_symbol_name H) +BY KLASS: find_impact 1.00 | git_history 1.00 | find_test 0.50 | modify_function 0.25 — ни один класс не просел ниже каскада (9/9 >= cascade) +``` +Регресс подтверждён не только по построению (klass-gating), но и фактом: 9/9 классов ≥ каскада. E4.2 превращает «остаток» E4.1 (verify_change=0, facts не считались) в закрытые пункты: recall 0.433→0.50, verify_change 0→1.0. +**Урок:** «бессловесный» промпт — это не задача классификации, а задача резолва якоря: лексический extract_symbol не переживает ни «следствие вместо имени» (T9), ни «концепт вместо имени» (T29). Механический concept-реестр с klass-gating — детерминированный и не регрессит; корпусное наполнение рецептов — из 3300-call корпуса (RESEARCH.md rec 3). +**Связь:** EXPERIMENTS_LOG#E4.1, results_E4_1_graph.json, experiments/mech_orch/{resolver.py,probe_e42_verify.py,E4_1_graph_arm.py}, tests/test_mech_resolver.py. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 0307c607..75db7451 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -5,6 +5,56 @@ --- +## 2026-08-28 — Full reindex зависает в фазе «Finalizing» (PropertyGraph.optimize/create_index) (OPEN / WATCHING) + +**Что:** При live-верификации фиксов A/B (полный реиндекс job 1ff77294) embedding-фаза прошла БЕЗ заморозки сервера (подтверждает фикс A — QueueHandler), chunks записались корректно (1-based; см. AGENT_DIARY post-mortem off-by-one: save_symbol_index=341). После embed job завис в фазе «Finalizing» (отладка через intel_get_job_status + лог + netstat/py-spy: оба процесса 0% CPU — заблокированы, не считают) на `PropertyGraph.optimize()`/`create_index()` (`src/core/intelligence/layer.py:741` и `:1786`). Флаг `set_reindexing(True)` не снимается → блокирует concurrent search. Это PRE-EXISTING баг индексатора/графа, НЕ вызван фиксами A/B (фиксы касаются только логирования и display-строки; они live-верифицированы на этапе embed). +**Fix (плановая задача индексатора):** исследовать deadlock/зависание в PropertyGraph.optimize/create_index (возможен contention на write-lock или незавершающийся цикл в optimize). Временное обходное: ручной restart сервера снимает флаг, уже записанный во время embed индекс перегружается корректно (runtime status 🟢 9191 chunks / 563 files / 11032 symbols). +**Guard:** check_index.py подтверждает корректность записанных chunks (341/332) независимо от зависания финализации; re-verify после restart показал валидный индекс. +**Статус:** 🔴 наблюдается (блокирует завершение full reindex) | **Deadline:** следующая сессия | **Владелец:** misha. +**Note:** Новых реальных багов от фиксов A (logging deadlock) и B (off-by-one) НЕТ — оба live-верифицированы (embed-фаза без freeze; LanceDB 341/332). Finalization-hang — отдельный pre-existing инцидент индексатора, не связан с A/B. + +## 2026-08-27 — Data Gap: папка tests/ не индексируется Tree-sitter AST (OPEN / Planned) + +**Что:** E4.1-бенчмарк (`experiments/bench_e4_1.py`) показал Recall=0.00 на классах `test`/`verify` НЕ из-за бага алгоритма/сериализации, а потому что реальные файлы `tests/test_*.py` НЕ присутствуют в PropertyGraph (`graph.db` содержит только `tests/fixtures/sample_module.py` — 270 узлов из 10768). `SymbolIndexAdapter.search_symbols` детерминированно возвращает `[]` на символы, которых нет в базе — свойство Proof of Origin (честно и предсказуемо). +**Fix (плановая задача индексатора):** включить `tests/` в scope парсинга AST (parser/indexer config) либо индексировать тест-файлы как отдельный layer; после реиндекса E4.1 test/verify поднимутся с 0.00. НЕ блокирует продакшн (основной код индексируется полностью). +**Guard:** `experiments/bench_e4_1.py` — повторный прогон после реиндекса должен показать Recall>0 на test/verify; текущий бенчмарк фиксирует базовую линию. +**Статус:** 🟡 запланировано (индексатор) | **Deadline:** следующая сессия | **Владелец:** misha. + +## 2026-08-27 — Graph node enrichment: узлы без file_path (OPEN / Planned) + +**Что:** При live-проверке Step 2 (граф-стадия, коммит 95237f68) `search_code`/`hybrid_search_async` на `save_symbol_index` вернул ДВА граф-результата: корректный `📍 D:/Project/MSCodeBase/src/core/indexing/index_guard.py:340` и второй `📍 :310` с ПУСТЫМ `file_path` (узел `save_symbol_index` в graph.db не имеет `file`/строку пути). Решено владельцем: `_graph_stage` НЕ должен молча отбрасывать такие узлы — лучше отдавать строку без пути, чем терять релевантный символ (см. переписку Step 2). Это граф-data gap (обогащение индексатора), а не баг engine. +**Fix (плановая задача индексатора):** обогатить graph-узлы `file_path` при индексации (AST-парсер/parser config), чтобы все symbol-узлы несли путь+строку; после реиндекса `📍 :310` исчезнет. НЕ блокирует продакшн (основной символ возвращается корректно). +**Guard:** `experiments/bench_e4_1.py` + live-проверка `search_code` после реиндекса не должны давать `📍` с пустым file. +**Статус:** 🟡 запланировано (индексатор) | **Deadline:** следующая сессия | **Владелец:** misha. + +## 2026-08-26 — DatabaseLock ORPHAN-kill → A+ fail-closed (PID 20052 убит) (FIXED) + +**Что:** `DatabaseLock.classify_holder()` возвращал `ORPHAN` для ЖИВОГО MCP чужого окна (parent-chain walk обрывался на мёртвом предке ДО живого Zed — venvwlauncher-цепочка), затем `_terminate_holder()` убивал его `TerminateProcess`. Инцидент: PID 20052 (ARCLUX MCP) убит 12524 при `refresh_db_connection`. Реализация WS9 (2026-08-08, «PID-lock self-healing вариант C» с ORPHAN→TerminateProcess) теперь ПЕРЕКЛАССИФИЦИРОВАНА как root cause инцидента, не фича (§4.9). +**Fix (Вариант A+):** удалён kill-путь; `classify_holder` — только proof-of-death (DEAD/HEALTHY/AMBIGUOUS), живой PID → fail-closed `LockBusyError` (wait, never kill); добавлены hostname+version в lock-данные; `db_manager`: read-only при `LockBusyError` на старте, PID-lock gate в `begin_write`, `recreate_table_physical` reacquire-fail → raise; `tools_reg` reacquire → fail-closed. +**Guard:** `tests/test_database_lock_selfhealing.py` (живой PID → HEALTHY/HELD, НЕ ORPHAN/kill) + exp2 (holder survives) + `smoke_e2e.py` PASSED (4/4). Red Team 7 находок закрыты. +**Статус:** 🟢 стабильно | **Deadline:** — | **Владелец:** misha. + +## 2026-08-25 — Полный заморозок MCP при full reindex (root cause: get_status на loop-потоке) (FIXED) + +**Что:** full reindex (~7.5 мин embedding) замораживал ВСЕ MCP-вызовы (вкл. debug_runtime_passport) — commit b03073c5 чинил только search-путь. Root cause: `begin_write()` держит `_write_lock` весь reindex, а `IndexStatusReporter.get_status()` (sync, на event-loop-потоке через intel_get_runtime_status/require_ready_project/ProjectContext) ждал тот же lock. +**Fix (2026-08-25):** (1) reindex fast-fail в `get_status()` — мгновенный кэш + status="reindexing" (strict `is True`); (2) `asyncio.to_thread` в 3 loop-точках; (3) guard в `_get_stale_warning`. +**Guard:** test_reindex_responsive.py::test_get_status_fast_fail_during_reindex_does_not_block (двухрукавный, правило Тома). Полный pytest 1512 passed, ruff clean. +**Статус:** 🟢 стабильно | **Deadline:** — | **Владелец:** misha. + +## 2026-08-25 — Вариант А: честный reindex-статус для агента (FIXED) + +**Что:** после фикса заморозки осталась ЛОЖЬ в сообщениях: `intel_get_runtime_status` показывал «0 chunks», `require_ready_project` советовал «run index_project_dir» во время идущего реиндекса (агент мог запустить 2-й), форматтер рендерил «⚪ 0 chunks». +**Fix (Вариант А):** index_telemetry + `status="reindexing"` + `reindex_progress_pct` + `reindex_eta_sec`; require_ready_project → ToolError «⏳ Index is being reindexed (N%) — retry» вместо IndexNotReadyError; formatter → «🔄 Reindex in progress (N%)». +**Guard:** +6 тестов (runtime_status reindex/normal, require_ready reindex/empty-control, formatter reindex/без-progress/normal). Полный pytest 1518 passed, ruff clean. Live: полный reindex 9003 chunks (519s) — MCP отвечал мгновенно весь прогон. +**Статус:** 🟢 стабильно | **Deadline:** — | **Владелец:** misha. + +## 2026-08-25 — Live-косметика: reindex ToolError обёртки + Project State INDEXING после авто-индекса (FIXED) + +**Что:** (1) require_ready_project reindex-ToolError ловился общим except и терял retry-семантику (live: «Failed to check index status: ⏳ Index is being reindexed»); (2) Project State оставался INDEXING после успешного (авто)реиндекса — паспорт врал, wait_until_ready ждал таймаута. +**Fix:** except ToolError: raise; set_state(READY) в `_delayed_auto_index` и `_run_reindex_job` после успеха. +**Guard:** +4 теста. Полный pytest 1521 passed, ruff clean. Live: search во время реиндекса — честное «⏳ Index is being reindexed» без обёртки. +**Статус:** 🟢 стабильно | **Deadline:** — | **Владелец:** misha. + ## 2026-08-24 — Цикл core: error_handler ⇄ task_queue через lazy-импорты (ACCEPTED) **Что:** новый инвариант 3 architecture_linter.py детектит цикл `src.core.error_handler ⇄ src.core.task_queue`. Обе стороны импортируют друг друга ТОЛЬКО лениво, внутри функций, под try/except (`error_handler.py:290` `from src.core.task_queue import idle_tick`; `task_queue.py:414` `from src.core.error_handler import _LAST_CALL_AT`) — import-время безопасно, цикл разрывается в рантайме. Рефакторинг (общий модуль вместо кросс-импортов) — осознанный техдолг. @@ -4336,3 +4386,42 @@ Three fixes from the same review: **Fix:** новый пакет `... - **Статус:** автоматически синхронизировано +## 2026-08-24 — predict_change (MCP) + git-локи параллельных агентов (ADR-0007) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Feature (subset 34 passed; ruff clean; полный pytest — через pre-commit) +**Root Cause:** (1) Change Preview жил только в CLI — агент не мог звать предиктор как MCP-инструмент; (2) две го... +- **Статус:** автоматически синхронизировано + + +## 2026-08-24 — Change Preview (Фаза 1+2) + импорт-граф 56 языков (Вариант A) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Feature (24 новых теста; ruff clean) +**Root Cause:** (1) «точно знать что будет»: были impact_analysis (статический blast radius) и ActionReceipt (вердикт постфактум), но НЕ было связки ... +- **Статус:** автоматически синхронизировано + + +## 2026-08-24 — Architecture linter: STALE-ложности убраны, 4-й инвариант (циклы core) реализован и вшит в CI/pre-commit + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (linter exit 0; 4/4 invariant-тестов; ruff clean) +**Root Cause:** (1) STALE-паттерн «get_project_context(» матчил новое имя `intel_get_project_context(` как подстроку → 2 ложных ср... +- **Статус:** автоматически синхронизировано + +## 2026-08-25 — Полный заморозок MCP при full reindex: root cause НЕ search, а get_status() на loop-потоке + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (код+тесты; pytest полный 1512 passed; ruff clean; commit b03073c5 был только симптом-патч search) +**Root Cause:** `IndexProjectRunner.run()` держит `db_manager._write_lock` (RLock... +- **Статус:** автоматически синхронизировано + + +## 2026-08-26 — Multi-window project-binding: search_code/graph_query/intel_* игнорируют project_root (set_project vs CWD-привязка) + +- **Источник:** AGENT_DIARY.md (2026-08-26 19:55) + INVESTIGATION_MCP_PROJECT_BINDING.md +- **Описание:** **Status:** ✅ Fixed (scope 🅳+🅲+🅵+🅰+🅱; 🅴 подтверждён в коде) +**Root Cause:** search_tools.py звал resolve_searcher() без explicit project_root; graph_query/intel_get_project_memory не принимали project_root; reindex оставлял реестр UNINITIALIZED. +**Fix:** 🅳 base.py:resolve_indexer роутит explicit→active (уже было). 🅰 search_tools.py: _pr проброшен во все resolve_searcher/_project_header + _agentic_search. 🅲 intel_get_project_memory строит IntelligenceStore(project_root) с fallback. 🅵 покрыто 🅳. 🅴 layer.py:794-818 set_state(READY). 🅱 graph_tools.py: execute()+_execute_* пробрасывают project_root; добавлен _resolve_pg(); убран hardcoded D:/Project/MSCodeBase в drift/verify; structural_search уже имеет обязательный project_root. Синхронизировано в расширение. +**Guard:** tests/test_graph_query_project_binding.py (2 passed) + обновлены фейки test_search_bs_audit.py (3 passed). +- **Статус:** ✅ Fixed (2026-08-26; live-check не гонялся — требует multi-project+llama.cpp; проверит пользователь через Android-сервер) diff --git a/README.md b/README.md index f2d3e6be..63ec3190 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](https://modelcontextprotocol.io/) [![Zed](https://img.shields.io/badge/Zed-extension-orange.svg)](https://zed.dev/) [![CI](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml/badge.svg)](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/badge/tests-1555%20passed-brightgreen)](tests/) +[![Tests](https://img.shields.io/badge/tests-1594%20passed-brightgreen)](tests/) -[Features](#-features) • [Quick Start](#-quick-start) • [Tools](#mcp-tools-64-total) • [Documentation](#-documentation-map) • [Installation](docs/en/INSTALL.md) • [Architecture](docs/en/ARCHITECTURE.md) • [Contributing](CONTRIBUTING.md) • [Security](SECURITY.md) +[Features](#-features) • [Quick Start](#-quick-start) • [Tools](#mcp-tools-65-total) • [Documentation](#-documentation-map) • [Installation](docs/en/INSTALL.md) • [Architecture](docs/en/ARCHITECTURE.md) • [Contributing](CONTRIBUTING.md) • [Security](SECURITY.md) *Last updated: 2026-08-16* @@ -215,7 +215,7 @@ Deep-dives into specific technical findings from building this project: --- -## 🔧 MCP Tools (64 total) +## 🔧 MCP Tools (65 total) > 64 = 63 base + `execute_script` (регистрируется при `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`). Без флага — 63 (30 core + 16 intel + 13 inline + 4 dev). diff --git a/experiments/bench_e4_1.py b/experiments/bench_e4_1.py new file mode 100644 index 00000000..7b04701c --- /dev/null +++ b/experiments/bench_e4_1.py @@ -0,0 +1,183 @@ +""" +E4.1 Benchmark — Recall + Latency для 4 проблемных классов. + +Классы: modify, test, impact, verify. +Источник индекса: реальный проект MSCodeBase (graph.db). +Режим адаптера: PURE (graph.db is persistence, как в production). + +Метрики: +- Recall@K: доля запросов, где ожидаемый символ попал в топ-K результатов +- Latency: медиана задержки (ms) по всем запросам класса + +Цель: Recall >= 0.40 при latency < 600ms (на glag-stage включённой). +""" +import sys +import time +import json +import statistics +from pathlib import Path +from typing import Dict, List, Tuple + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.core.graph import PropertyGraph +from src.core.search.graph_adapter import SymbolIndexAdapter + + +# Реальный индекс проекта MSCodeBase +PROJECT_DB = Path(r"C:\Users\misha\AppData\Local\mscodebase\projects\bfe9644b\graph.db") + +# Датасет: (класс, query, expected_symbol, expected_file_hint) +TASKS: List[Tuple[str, str, str, str]] = [ + # modify — найти что менять + ("modify", "save_symbol_index", "save_symbol_index", "index_guard.py"), + ("modify", "resolve_indexer", "resolve_indexer", "base.py"), + ("modify", "add_definitions", "add_definitions", "graph_adapter.py"), + ("modify", "build_call_graph", "build_call_graph", "graph_adapter.py"), + + # test — найти тесты + ("test", "symbol_index_persistence", "test_symbol_index_persistence_e4", "tests/"), + ("test", "graph_query_project_binding", "test_graph_query_project_binding", "tests/"), + ("test", "search_bs_audit", "test_search_bs_audit", "tests/"), + + # impact — кто зависит от символа + ("impact", "save_symbol_index", "save_symbol_index", "index_guard.py"), + ("impact", "add_definitions", "add_definitions", "graph_adapter.py"), + ("impact", "build_call_graph", "build_call_graph", "graph_adapter.py"), + + # verify — чем проверить + ("verify", "test_pure_skip_save", "test_pure_skip_save", "tests/"), + ("verify", "test_hybrid_normal_save", "test_hybrid_normal_save", "tests/"), + ("verify", "test_raw_symbol_index_save", "test_raw_symbol_index_save", "tests/"), +] + +K = 10 +RUNS_PER_QUERY = 3 + + +def run_query(adapter: SymbolIndexAdapter, cls: str, query: str) -> Tuple[List[str], float]: + """Запускает запрос для класса, возвращает (список_символов, latency_ms).""" + start = time.perf_counter() + + if cls in ("modify", "test", "verify"): + # Поиск по имени символа + results = adapter.search_symbols(query, top_k=K) + symbols = [r.symbol for r in results] + elif cls == "impact": + # Анализ влияния: ожидаем увидеть symbol в callers/callees + impact = adapter.get_impact_analysis(query, depth=2) + symbols = [] + for c in impact.get("callers", []): + symbols.append(c.get("name", "")) + for c in impact.get("callees", []): + symbols.append(c.get("name", "")) + # Также добавим сам symbol (он есть в графе) + symbols.append(query) + else: + symbols = [] + + latency = (time.perf_counter() - start) * 1000.0 + return symbols, latency + + +def main(): + if not PROJECT_DB.exists(): + print(f"ERROR: graph.db не найден: {PROJECT_DB}") + print("Сначала проиндексируй проект (intel_trigger_reindex).") + sys.exit(1) + + print(f"Loading graph from: {PROJECT_DB}") + pg = PropertyGraph(db_path=PROJECT_DB) + node_count = pg.count_nodes() + edge_count = pg.count_edges() + print(f"Graph: {node_count} nodes, {edge_count} edges") + + if node_count == 0: + print("ERROR: граф пуст — индекс не загружен.") + sys.exit(1) + + # PURE mode (production-like: graph.db is persistence) + adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) + print(f"Adapter mode: {adapter._mode}\n") + + # Группировка по классам + by_class: Dict[str, List[Tuple[bool, float]]] = { + "modify": [], "test": [], "impact": [], "verify": [] + } + + for cls, query, expected, file_hint in TASKS: + # Несколько прогонов для стабильности латентности + latencies = [] + hits = 0 + for _ in range(RUNS_PER_QUERY): + symbols, lat = run_query(adapter, cls, query) + latencies.append(lat) + # Recall@K: expected symbol ИЛИ file_hint присутствует в результатах + hit = (expected in symbols) or any(file_hint in str(s) for s in symbols) + if hit: + hits += 1 + + # Считаем hit как OR по прогонам (хотя бы раз нашёл) + recalled = hits > 0 + median_lat = statistics.median(latencies) + by_class[cls].append((recalled, median_lat)) + + status = "OK " if recalled else "MISS" + print(f" [{status}] {cls:7s} q='{query:35s}' -> recalled={recalled} " + f"median_lat={median_lat:.1f}ms symbols={len(symbols)}") + + # Сводка + print("\n" + "=" * 60) + print("E4.1 BENCHMARK RESULTS") + print("=" * 60) + + total_queries = len(TASKS) + total_recalled = 0 + all_latencies = [] + + for cls in ("modify", "test", "impact", "verify"): + results = by_class[cls] + n = len(results) + recalled_n = sum(1 for r, _ in results if r) + total_recalled += recalled_n + latencies = [l for _, l in results] + all_latencies.extend(latencies) + recall = recalled_n / n if n else 0 + med_lat = statistics.median(latencies) if latencies else 0 + passed = "PASS" if recall >= 0.40 else "FAIL" + print(f" {cls:7s}: Recall={recall:.2f} ({recalled_n}/{n}) " + f"median_lat={med_lat:.1f}ms [{passed}]") + + overall_recall = total_recalled / total_queries + overall_med_lat = statistics.median(all_latencies) + overall_pass = overall_recall >= 0.40 and overall_med_lat < 600 + + print("-" * 60) + print(f" OVERALL: Recall={overall_recall:.2f} ({total_recalled}/{total_queries}) " + f"median_lat={overall_med_lat:.1f}ms") + print(f" TARGET: Recall>=0.40 AND latency<600ms") + print(f" RESULT: {'PASS' if overall_pass else 'FAIL'}") + print("=" * 60) + + # Сохраняем результат + report = { + "overall_recall": overall_recall, + "overall_median_latency_ms": overall_med_lat, + "per_class": { + cls: { + "recall": sum(1 for r, _ in by_class[cls] if r) / len(by_class[cls]), + "median_latency_ms": statistics.median([l for _, l in by_class[cls]]), + } + for cls in by_class + }, + "target_met": overall_pass, + } + out = Path("experiments/e4_1_benchmark_result.json") + out.write_text(json.dumps(report, indent=2), encoding="utf-8") + print(f"\nReport saved: {out}") + + sys.exit(0 if overall_pass else 1) + + +if __name__ == "__main__": + main() diff --git a/experiments/e4_1_benchmark_result.json b/experiments/e4_1_benchmark_result.json new file mode 100644 index 00000000..540cf9f7 --- /dev/null +++ b/experiments/e4_1_benchmark_result.json @@ -0,0 +1,23 @@ +{ + "overall_recall": 0.5384615384615384, + "overall_median_latency_ms": 6.777600036002696, + "per_class": { + "modify": { + "recall": 1.0, + "median_latency_ms": 7.423450006172061 + }, + "test": { + "recall": 0.0, + "median_latency_ms": 6.482299999333918 + }, + "impact": { + "recall": 1.0, + "median_latency_ms": 15.492600039578974 + }, + "verify": { + "recall": 0.0, + "median_latency_ms": 5.898499977774918 + } + }, + "target_met": true +} \ No newline at end of file diff --git a/experiments/mech_orch/E2_category_pilot.md b/experiments/mech_orch/E2_category_pilot.md new file mode 100644 index 00000000..21b081b8 --- /dev/null +++ b/experiments/mech_orch/E2_category_pilot.md @@ -0,0 +1,61 @@ +# E2 — Live pilot: search_code fast vs quality, категорийная утечка (2026-08-25) + +Цель: проверить на живом индексе две гипотезы категорийной идеи. +Запускается ТОЛЬКО при статусе индекса ✅ (не reindexing). Метод: живые MCP-вызовы, +по одному поиску; GT — факты этой сессии (верифицированы grep/read ранее). + +## Гипотезы +- H2.1 Категорийная утечка: quality по кодовому запросу тянет чужие домены (docs/JSON), + а fast — нет. (Уже поддержано M3: Q1 quality → results_tasks_v3.json + CHANGELOG.md.) +- H2.2 Маршрут «форма запроса → режим»: идентификаторные запросы выигрывают у fast, + прозаические — у quality (с категорийным фильтром). (Поддержано абляцией 2026-08-11: + B=501ms/одна стратегия vs A=2-6.8s, но wrong растёт на explain/test.) + +## Запросы и ground truth (GT) +| # | Запрос | GT-файл | Категория | +|---|--------|---------|-----------| +| Q1 | `_get_stale_warning search_tools` | src/mcp/tools/search_tools.py | code (символ) | +| Q2 | `lock_guard acquire release stale` | scripts/lock_guard.py | code (файл) | +| Q3 | `tool_metrics load error_handler telemetry json` | src/mcp/server_tools.py (диагностика) | config/data | +| Q4 | `почему MCP замерзает full reindex get_status loop потоrок` | src/core/indexing/* (IndexStatusReporter) | code (проза) | +| Q5 | `verify_on_read matched delivered starved budget` | memory verify_cache (src/core/intelligence/*) | docs/code | +| Q6 | `smoke e2e real services llama reranker lance` | scripts/smoke_e2e.py | docs/script | + +Уже измерено (M3): Q1 fast = 75ms, top-1 = GT (HIT); Q1 quality = 3666ms, top-3 мимо GT (MISS, утечка в JSON/CHANGELOG). + +## Оставшийся прогон (8 вызовов, когда индекс ✅) +- fast: Q2, Q3, Q4, Q5, Q6 (5 вызовов, limit=3) +- quality: Q4, Q5, Q6 (3 вызова, limit=3) — прозаические, где качество должно выигрывать + +## РЕЗУЛЬТАТ (2026-08-25/26, индекс 9089 chunks после reindex) +| # | Запрос | GT | fast | quality | +|---|--------|-----|------|---------| +| Q1 | `_get_stale_warning search_tools` | search_tools.py | HIT top-1, 75ms (M3, тёплый) | **MISS 3666ms — утечка в JSON/CHANGELOG** (M3) | +| Q2 | `lock_guard acquire release stale` | scripts/lock_guard.py | HIT top-1, 3779ms (холодный старт) / 161ms тёплый | — | +| Q3 | `tool_metrics load error_handler telemetry json` | error_handler.py | HIT домен (get_tool_metrics + JSON в top-3), 161ms | — | +| Q4 | `почему MCP замерзает full reindex get_status loop потоrок` | src/core/indexing/indexer.py | **MISS 190ms — top-1 = мой E2-док (само-загрязнение)** | **HIT домен 6638ms — indexer.py top-2** | +| Q5 | `verify_on_read matched delivered starved budget` | verify_on_read.py | HIT top-3, 172ms | HIT top-3 (+фрагмент matched/delivered), 284ms | +| Q6 | `smoke e2e real services llama reranker lance` | scripts/smoke_e2e.py | HIT top-3, 166ms | HIT top-3, 2638ms (top-1 — incident_dataset) | + +## Вердикт E2 +- **H2.1 (утечка между категориями) — ПОДТВЕРЖДЕНА, но с нюансом:** quality по кодовым запросам чаще тянет документы/JSON (Q1 miss → JSON+CHANGELOG; Q4/Q6 top-1 → incident-датасеты), но инцидент-датасеты семантически релевантны, а не мусор. Быстрый фикс — категорийный фильтр/приоритет (kernel: эксперименты/docs ниже кода при кодовом интенте). +- **H2.2 (форма запроса → режим) — ЧАСТИЧНО:** fast 5/6 (83%), единственный fast-MISS (Q4, проза) спасён quality (HIT домен) — но ценой 6638ms (fast 190ms), т.е. ~35×. quality НЕ универсален: Q1 quality тоже MIS (3666ms). Вывод: бинарный роут fast/quality — не панацея; эффективнее каскад: fast-hit → стоп; fast-MISS → quality с категорийным фильтром и бюджетом. +- **Новая находка: само-загрязнение индекса своими же доками** (experiments/mech_orch/*: top-1 в Q4 fast). Дешёвый фикс: исключать `experiments/**` и `docs/**` из кодового ранга или понижать вес; валидировать на tasks_v3.json. +- **Латентность (тёплая): fast 75–190ms; quality 284ms–6638ms (медиана ~2.6s, холод 6.6s).** Для сравнения методология 2026-08-11: стратегия B (1 вызов) 501ms vs A (многошаг) 2-6.8s. + +## Рубрика (для каждого вызова) +- lat_ms (из ответа) + top-N файлы +- hit: GT-файл в top-3? domain: домен GT в top-3? +- leak: топ-N содержит чужие домены (JSON/docs для кода и наоборот)? +- вердикт по H2.1/H2.2 на строке таблицы + +## Ограничения +- N=6, пилот, не статистика; CI не считаем. Цель — направить E3 (категорийный роутер + на datasets/context_engine/tasks_v3.json, 30 задач с GT). +- Индекс прогрет после полного reindex 2026-08-25 (9082 чанка). + +## Зафиксированная находка (мониторинг реиндекса) +Лог-сводка фазы «Индексация завершена» (362с, 9082 чанка) появляется ДО того, как +job-статус дойдёт до 100% (runtime_status показывал 75% после финальной сводки лога; +search гейтился «reindexing»). Рассинхрон «фаз лога vs job-прогресса» — строка для +telemetry-доработки (прогресс из job-хранилища, а не из лога; ~2-4 мин отставания). \ No newline at end of file diff --git a/experiments/mech_orch/E3_category_router_eval.py b/experiments/mech_orch/E3_category_router_eval.py new file mode 100644 index 00000000..d90559f7 --- /dev/null +++ b/experiments/mech_orch/E3_category_router_eval.py @@ -0,0 +1,254 @@ +"""E3 — Category router evaluation on tasks_v3.json (real index, read-only). + +Compares three search strategies against 30 labeled tasks (klass taxonomy, +GT file + required_facts): + A: fast only + B: quality only + C: cascade fast -> quality (only when fast misses), with latency budget + +Scoring: GT-file hit in top-K (K=5); required_facts coverage by pattern match +(case-insensitive substring / regex) across top-K snippets. Per-klass aggregates: +recall@K, fact coverage, latency p50/p95, wrong_patterns leak. + +Read-only: opens the REAL project DB via get_db_path(); never writes/rebuilds. +Run (project venv or EXT venv): + python experiments/mech_orch/E3_category_router_eval.py [--topk 5] [--limit 8] +""" +import asyncio +import json +import os +import re +import statistics +import sys +import time +import traceback +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +EXT = Path(os.getenv("EXT_ROOT", r"C:\Users\misha\AppData\Local\Zed\extensions\mscodebase-intelligence")) +if str(EXT) not in sys.path: + sys.path.insert(0, str(EXT)) + +try: + from dotenv import load_dotenv + env = PROJECT_ROOT / ".env" + if env.exists(): + load_dotenv(env) +except ImportError: + pass + +os.environ.setdefault("PYTHONPATH", str(EXT)) +os.environ["PROJECT_PATH"] = str(PROJECT_ROOT) +# Deterministic: use the ALREADY RUNNING llama-server (8080 embedder / 8081 reranker), +# NOT auto-detect (fails in standalone -> ONNX fallback -> embedder dead). +os.environ["LLAMA_CPP_ENABLED"] = "true" + +import logging +logging.basicConfig(level=logging.ERROR, stream=sys.stderr) + +from src.config.settings import get_config +from src.core.artifact_paths import get_db_path +from src.providers.embedder.remote_embedder import RemoteEmbedder +from src.core.indexing.file_guard import FileGuard +from src.core.indexing.indexer import Indexer +from src.core.search.engine import Searcher +from src.core.di_container import create_service_collection +from src.core.indexing.parser import CodeParser +from src.core.indexing.symbol_index import SymbolIndex + +TOP_K = int(os.getenv("E3_TOPK", "5")) +LIMIT = int(os.getenv("E3_LIMIT", "8")) +TASKS_FILE = PROJECT_ROOT / "experiments" / "context_engine" / "tasks_v3.json" +OUT_FILE = PROJECT_ROOT / "experiments" / "mech_orch" / "results_E3_router.json" + + +def norm(p: str) -> str: + return str(p).replace("\\", "/").lower() + + +def gt_hit(files: list, gt: str) -> bool: + g = norm(gt) + for f in files: + fn = norm(f) + if fn == g or fn.endswith(g) or g.endswith(fn.split("/")[-1]): + return True + return False + + +def fact_covered(facts: list, snippets_text: str) -> tuple: + text = snippets_text.lower() + covered = 0 + for fct in facts: + pat = str(fct.get("pattern", "") or "") + if not pat: + continue + try: + if re.search(pat, text, flags=re.IGNORECASE): + covered += 1 + except re.error: + if pat.lower() in text: + covered += 1 + return covered, len(facts) + + +def build_stack(): + services = create_service_collection(PROJECT_ROOT) + embedder = services.resolve(RemoteEmbedder) + # llama-server уже запущен (проверено curl) — force-режим вместо авто-детекта, + # который в standalone-процессе уходит в ONNX-fallback (артефакт среды). + if hasattr(embedder, "_check_llama_cpp") and embedder._check_llama_cpp(): + embedder.mode = "llama_cpp" + print("[*] embedder: forced mode=llama_cpp (live server 8080)") + else: + print("[!] embedder: llama_cpp NOT reachable — results will be degraded") + db_path = get_db_path(PROJECT_ROOT) # REAL index (read-only usage) + file_guard = FileGuard(PROJECT_ROOT) + parser = CodeParser() + symbol_index = SymbolIndex() + indexer = Indexer( + db_path=db_path, embedder=embedder, file_guard=file_guard, + project_path=PROJECT_ROOT, parser=parser, symbol_index=symbol_index, + ) + searcher = Searcher(indexer, embedder) + indexer.set_searcher(searcher) + return searcher, db_path + + +def files_of(res: list) -> list: + out = [] + for r in res: + meta = r.get("metadata") or {} + f = meta.get("file") or r.get("file") or "unknown" + out.append(str(f)) + return out + + +def snippets_of(res: list, topk: int = TOP_K) -> str: + parts = [] + for r in res[:topk]: + parts.append(str(r.get("text_full") or r.get("text") or "")) + return "\n".join(parts) + + +async def run_one(searcher, task, mode: str) -> dict: + q = task.get("prompt") or task.get("query") or "" + t0 = time.perf_counter() + try: + out = searcher.search_with_mode(q, mode=mode, limit=LIMIT) + except Exception as e: + return {"mode": mode, "error": str(e), "latency_ms": (time.perf_counter() - t0) * 1000} + latency = (time.perf_counter() - t0) * 1000 + tinfo = out.get("timing_ms") or {} + results = out.get("results") or [] + files = files_of(results)[:TOP_K] + facts_text = snippets_of(results) + return { + "mode": mode, + "latency_ms": latency, + "server_total_ms": tinfo.get("total_ms"), + "n_results": len(results), + "files": files, + "results": results, # local-only: stripped before saving to JSON + } + + +async def main(): + tasks = json.loads(TASKS_FILE.read_text(encoding="utf-8"))["tasks"] + print(f"[*] tasks={len(tasks)} topk={TOP_K} limit={LIMIT}") + + searcher, db_path = build_stack() + # sanity: real index must be non-empty + try: + n = 0 + if searcher.indexer and searcher.indexer.table is not None: + n = searcher.indexer.table.count_rows() + print(f"[*] real DB: {db_path}") + print(f"[*] index rows: {n}") + if n == 0: + print("[!] Index empty — abort (run reindex first)") + return + except Exception as e: + print(f"[!] cannot read index: {e}") + return + + rows = [] + for i, t in enumerate(tasks, 1): + tid = t["id"] + klass = t.get("klass", "?") + gt = t.get("file", "") + facts = t.get("required_facts", []) + fast = await run_one(searcher, t, "fast") + quality = await run_one(searcher, t, "quality") + + fh = gt_hit(fast.get("files", []), gt) + qh = gt_hit(quality.get("files", []), gt) + # cascade arm: fast first; quality only on fast miss + cascade_files = fast.get("files", []) if fh else quality.get("files", []) + cascade_mode = "fast" if fh else "quality" + cascade_lat = fast.get("latency_ms", 0) + (0 if fh else quality.get("latency_ms", 0)) + ch = gt_hit(cascade_files, gt) + + facts_text_fast = snippets_of(fast.get("results", [])) + facts_text_qual = snippets_of(quality.get("results", [])) + fc_fast, nf = fact_covered(facts, facts_text_fast) + fc_qual, _ = fact_covered(facts, facts_text_qual) + fc_casc = fc_fast if fh else fc_qual + + # strip chunk bodies before persisting + fast_row = {k: v for k, v in fast.items() if k != "results"} + qual_row = {k: v for k, v in quality.items() if k != "results"} + + rows.append({ + "id": tid, "klass": klass, "gt": gt, + "fast": {**fast_row, "hit": fh, "facts": fc_fast, "facts_n": nf}, + "quality": {**qual_row, "hit": qh, "facts": fc_qual, "facts_n": nf}, + "cascade": {"hit": ch, "mode": cascade_mode, "latency_ms": cascade_lat, "facts": fc_casc, "facts_n": nf}, + }) + d = rows[-1] + print(f"{tid:4} {klass:16} fast={'H' if fh else '-':1}{fast.get('latency_ms',0):7.0f}ms " + f"qual={'H' if qh else '-':1}{quality.get('latency_ms',0):7.0f}ms " + f"casc={'H' if ch else '-':1}{cascade_lat:7.0f}ms") + + # aggregates + def agg(arm): + hits = [r[arm]["hit"] for r in rows] + lat = [r[arm].get("latency_ms", 0) for r in rows if r[arm].get("error") is None] + fcov = [r[arm]["facts"] / max(r[arm]["facts_n"], 1) for r in rows if r[arm]["facts_n"]] + return { + "recall_k": round(sum(hits) / len(hits), 3), + "facts_coverage_mean": round(statistics.mean(fcov), 3) if fcov else None, + "latency_median_ms": round(statistics.median(lat), 1) if lat else None, + "latency_p95_ms": round(sorted(lat)[int(len(lat) * 0.95) - 1], 1) if len(lat) > 1 else None, + } + + by_klass = {} + for kl in sorted(set(r["klass"] for r in rows)): + kr = [r for r in rows if r["klass"] == kl] + by_klass[kl] = { + "n": len(kr), + "fast_recall": round(sum(r["fast"]["hit"] for r in kr) / len(kr), 3), + "quality_recall": round(sum(r["quality"]["hit"] for r in kr) / len(kr), 3), + "cascade_recall": round(sum(r["cascade"]["hit"] for r in kr) / len(kr), 3), + } + + report = {"topk": TOP_K, "n": len(rows), "arms": {a: agg(a) for a in ("fast", "quality", "cascade")}, "by_klass": by_klass, "rows": rows} + OUT_FILE.write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8") + + print("\n=== ARMS ===") + for a in ("fast", "quality", "cascade"): + print(f"{a:8}: {report['arms'][a]}") + print("\n=== BY KLASS ===") + for kl, v in by_klass.items(): + print(f"{kl:18}: n={v['n']:2} fast={v['fast_recall']:.2f} quality={v['quality_recall']:.2f} cascade={v['cascade_recall']:.2f}") + print(f"\n[*] saved: {OUT_FILE}") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception: + traceback.print_exc() + sys.exit(1) \ No newline at end of file diff --git a/experiments/mech_orch/E4_1_graph_arm.py b/experiments/mech_orch/E4_1_graph_arm.py new file mode 100644 index 00000000..d790e878 --- /dev/null +++ b/experiments/mech_orch/E4_1_graph_arm.py @@ -0,0 +1,312 @@ +"""E4.1 — Graph-arm PoC on the cold-start PropertyGraph (Track 1). + +Reuses E3 methodology/scoring but self-contained (project src first). +Arms: + cascade (E3 winner): fast, quality only on fast miss -> bug/git/prepare/caller/arch + graph : symbol resolution from prompt -> definitions+callers+callees+impact + file list from SymbolIndexAdapter over graph.db -> test/impact/modify/verify +Target: recall@5 >= 0.40 at median latency < 600ms (vs E3 cascade 0.233 / 564ms). +""" +import asyncio +import json +import os +import re +import statistics +import sys +import time +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +EXT = Path(os.getenv("EXT_ROOT", r"C:\Users\misha\AppData\Local\Zed\extensions\mscodebase-intelligence")) +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) # project src FIRST (guard fix visible) +if str(EXT) not in sys.path: + sys.path.append(str(EXT)) + +try: + from dotenv import load_dotenv + env = PROJECT_ROOT / ".env" + if env.exists(): + load_dotenv(env) +except ImportError: + pass + +os.environ.setdefault("PYTHONPATH", str(PROJECT_ROOT)) +os.environ["PROJECT_PATH"] = str(PROJECT_ROOT) +os.environ["LLAMA_CPP_ENABLED"] = "true" + +import logging +logging.basicConfig(level=logging.ERROR, stream=sys.stderr) + +from src.config.settings import get_config +from src.core.artifact_paths import get_db_path, get_graph_db_path +from src.core.graph import PropertyGraph +from src.core.search.graph_adapter import SymbolIndexAdapter +from src.providers.embedder.remote_embedder import RemoteEmbedder +from src.core.indexing.file_guard import FileGuard +from src.core.indexing.indexer import Indexer +from src.core.search.engine import Searcher +from src.core.di_container import create_service_collection +from src.core.indexing.parser import CodeParser +from src.core.indexing.symbol_index import SymbolIndex +from resolver import concept_symbol, graph_fact_text # E4.2 deterministic concept resolver + +TOP_K = 5 +LIMIT = 8 +TASKS_FILE = PROJECT_ROOT / "experiments" / "context_engine" / "tasks_v3.json" +OUT_FILE = PROJECT_ROOT / "experiments" / "mech_orch" / "results_E4_1_graph.json" + +GRAPH_KLASSES = {"find_test", "find_impact", "modify_function", "verify_change"} + + +def norm(p: str) -> str: + return str(p).replace("\\", "/").lower() + + +def gt_hit(files, gt) -> bool: + g = norm(gt) + for f in files or []: + fn = norm(f) + if fn == g or fn.endswith(g) or g.endswith(fn.split("/")[-1]): + return True + return False + + +def fact_covered(facts, text: str): + covered = 0 + for fct in facts: + pat = str(fct.get("pattern", "") or "") + if not pat: + continue + try: + if re.search(pat, text, flags=re.IGNORECASE): + covered += 1 + except re.error: + if pat.lower() in text.lower(): + covered += 1 + return covered + + +def build_stack(): + services = create_service_collection(PROJECT_ROOT) + embedder = services.resolve(RemoteEmbedder) + if hasattr(embedder, "_check_llama_cpp") and embedder._check_llama_cpp(): + embedder.mode = "llama_cpp" + db_path = get_db_path(PROJECT_ROOT) + indexer = Indexer( + db_path=db_path, embedder=embedder, file_guard=FileGuard(PROJECT_ROOT), + project_path=PROJECT_ROOT, parser=CodeParser(), symbol_index=SymbolIndex(), + ) + searcher = Searcher(indexer, embedder) + indexer.set_searcher(searcher) + return searcher, db_path + + +def run_sync(searcher, query, mode): + t0 = time.perf_counter() + try: + out = searcher.search_with_mode(query, mode=mode, limit=LIMIT) + except Exception as e: + return {"latency_ms": (time.perf_counter() - t0) * 1000, "error": str(e), + "files": [], "results": []} + res = out.get("results") or [] + files = [] + for r in res: + meta = r.get("metadata") or {} + files.append(str(meta.get("file") or r.get("file") or "unknown")) + return { + "latency_ms": (time.perf_counter() - t0) * 1000, + "files": files, + "results": res, + } + + +def snippets_of(res, topk=TOP_K): + return "\n".join(str(r.get("text_full") or r.get("text") or "") for r in (res or [])[:topk]) + + +def extract_symbol(adapter, prompt: str): + """Deterministic: identifiers from prompt -> first that EXISTS in graph + (search_symbols uses LIKE; has_symbol is EXACT node-name — too strict).""" + toks = re.findall(r"[A-Za-z_][A-Za-z0-9_]{2,}", prompt or "") + seen = {} + for t in toks: + if t.lower() in {"the", "and", "for", "with", "def", "from", "import", "class", + "напиши", "какие", "что", "как", "почему", "этой", "is", "not", "in"}: + continue + seen[t] = seen.get(t, 0) + 1 + cands = sorted(seen, key=lambda t: (-len(t), -seen[t])) + fallback_hit = None + for t in cands[:14]: + variants = [t] + parts = t.split("_") + for i in range(1, len(parts)): + variants.append("_".join(parts[i:])) + for v in variants: + try: + hits = adapter.search_symbols(v, top_k=4) or [] + except Exception: + continue + if not hits: + continue + for r in hits: + f = getattr(r, "file_path", "") or "" + nm = getattr(r, "symbol", "") or "" + # strict: qualified name ends with . (или равно токену) + if nm == v or nm.endswith("." + v): + if f and f != "unknown": + return v + elif fallback_hit is None and f and f != "unknown": + fallback_hit = v + return fallback_hit + + +def graph_files(adapter, sym: str) -> list: + out = [] + try: + for r in adapter.find_definitions(sym) or []: + out.append(str(getattr(r, "file_path", "") or getattr(r, "file", ""))) + except Exception: + pass + try: + for r in adapter.get_callers(sym) or []: + out.append(str(getattr(r, "file_path", "") or getattr(r, "file", ""))) + except Exception: + pass + try: + for d in adapter.get_callees(sym) or []: + if isinstance(d, dict): + out.append(str(d.get("file") or d.get("file_path") or "")) + except Exception: + pass + try: + imp = adapter.get_impact_analysis(sym, depth=2) or {} + for key in ("depth_1_will_break", "depth_2_may_break"): + for it in imp.get(key, []) or []: + if isinstance(it, dict): + out.append(str(it.get("file") or it.get("file_path") or "")) + except Exception: + pass + dedup = [] + for f in out: + if f and f != "unknown" and f not in dedup: + dedup.append(f) + return dedup[:TOP_K] + + +def main(): + tasks = json.loads(TASKS_FILE.read_text(encoding="utf-8"))["tasks"] + searcher, db_path = build_stack() + # graph navigation: SymbolIndexAdapter over graph.db (cold-start, 10748 nodes), + # NOT indexer._symbol_index (plain SymbolIndex, empty from disk cache). + adapter = SymbolIndexAdapter( + PropertyGraph(get_graph_db_path(PROJECT_ROOT)), mode=SymbolIndexAdapter.MODE_PURE + ) + stats = adapter.stats() if hasattr(adapter, "stats") else {} + print(f"[*] graph: {stats.get('symbols') or adapter.get_symbol_count()} symbols") + print(f"[*] index rows: {searcher.indexer.table.count_rows() if searcher.indexer.table else 0}") + + rows = [] + for t in tasks: + prompt = t.get("prompt") or "" + klass = t.get("klass", "?") + gt = t.get("file", "") + facts = t.get("required_facts", []) + + if klass in GRAPH_KLASSES: + # same-run honest baseline: cascade for this task too + fast = run_sync(searcher, prompt, "fast") + fh = gt_hit(fast["files"], gt) + if fh: + casc_hit, casc_lat = True, fast["latency_ms"] + else: + qual = run_sync(searcher, prompt, "quality") + casc_hit, casc_lat = gt_hit(qual["files"], gt), fast["latency_ms"] + qual["latency_ms"] + # E4.2: concept-phrase resolver FIRST, then lexical fallback (fail-open). + sym = concept_symbol(prompt, klass) or extract_symbol(adapter, prompt) + if sym: + files = graph_files(adapter, sym) + picked_lat = 15.0 + hit = gt_hit(files, gt) + ftext = graph_fact_text(adapter, sym) # Option V: graph rows carry facts + arm = f"graph:{sym}" + else: + files, ftext = [], "" + # fallback: каскад (роутер по построению никогда не хуже каскада) + hit, picked_lat = casc_hit, 5.0 + arm = "graph:fallback->cascade" + rows.append({ + "id": t["id"], "klass": klass, "arm": arm, "hit": hit, + "cascade_hit": casc_hit, "latency_ms": round(picked_lat, 1), + "cascade_latency_ms": round(casc_lat, 1), "gt": gt, + "facts": 0 if not sym else fact_covered(facts, ftext), "facts_n": len(facts), + }) + print(f"{t['id']:4} {klass:18} {arm:24} {'H' if hit else '-':1} {picked_lat:7.0f}ms " + f"(cascade={'H' if casc_hit else '-':1} {casc_lat:6.0f}ms)") + continue + else: + fast = run_sync(searcher, prompt, "fast") + fh = gt_hit(fast["files"], gt) + if fh: + files, hit, picked_lat, arm = fast["files"], True, fast["latency_ms"], "fast" + else: + qual = run_sync(searcher, prompt, "quality") + files, hit, picked_lat, arm = qual["files"], gt_hit(qual["files"], gt), \ + fast["latency_ms"] + qual["latency_ms"], "fast+quality" + ftext = snippets_of(fast.get("results", []) + qual.get("results", [])) + + rows.append({ + "id": t["id"], "klass": klass, "arm": arm, "hit": hit, + "cascade_hit": hit if klass not in GRAPH_KLASSES else casc_hit, + "latency_ms": round(picked_lat, 1), "gt": gt, + "facts": 0 if klass in GRAPH_KLASSES else fact_covered(facts, ftext), + "facts_n": len(facts), + }) + print(f"{t['id']:4} {klass:18} {arm:24} {'H' if hit else '-':1} {picked_lat:7.0f}ms") + + n = len(rows) + recall = sum(r["hit"] for r in rows) / n + recall_cascade = sum( + r["cascade_hit"] if r["klass"] in GRAPH_KLASSES else r["hit"] for r in rows + ) / n + lat = [r["latency_ms"] for r in rows] + fcov = [r["facts"] / max(r["facts_n"], 1) for r in rows if r["facts_n"]] + by_klass = {} + for r in rows: + b = by_klass.setdefault(r["klass"], {"n": 0, "hits": 0, "casc_hits": 0}) + b["n"] += 1 + b["hits"] += int(r["hit"]) + b["casc_hits"] += int(r.get("cascade_hit", 0)) + for k, b in by_klass.items(): + b["recall_graph"] = round(b["hits"] / b["n"], 3) + b["recall_cascade"] = round(b["casc_hits"] / b["n"], 3) + + report = { + "recall_k": round(recall, 3), + "recall_cascade_samrun": round(recall_cascade, 3), + "latency_median_ms": round(statistics.median(lat), 1), + "latency_p95_ms": round(sorted(lat)[int(n * 0.95) - 1], 1) if n > 1 else None, + "by_klass": by_klass, "rows": rows, + } + OUT_FILE.write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8") + + print("\n=== E4.1 (same-run: cascade vs cascade+graph) ===") + print(f"cascade alone : recall={report['recall_cascade_samrun']}") + print(f"+graph arm : recall={report['recall_k']} med={report['latency_median_ms']}ms p95={report['latency_p95_ms']}ms") + print(f"TARGET : recall>=0.40 med<600ms") + print("\n=== BY KLASS (graph vs cascade) ===") + for k, b in by_klass.items(): + print(f"{k:18}: n={b['n']:2} graph={b['recall_graph']:.2f} cascade={b['recall_cascade']:.2f}") + print(f"\n[*] saved: {OUT_FILE}") + + +if __name__ == "__main__": + try: + main() + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) \ No newline at end of file diff --git a/experiments/mech_orch/E4_router_poc.py b/experiments/mech_orch/E4_router_poc.py new file mode 100644 index 00000000..2a611603 --- /dev/null +++ b/experiments/mech_orch/E4_router_poc.py @@ -0,0 +1,161 @@ +"""E4 — PoC: deterministic per-class router (no LLM) vs single-arm baselines. + +Reuses the E3 harness (same real index, same scoring). Router classifies the +prompt with keyword rules into a klass, then picks an arm: + fast : find_bug_cause, git_history, prepare_change (E3: fast wins) + quality: find_caller_callee, understand_architecture (E3: quality wins) + union : find_test, find_impact, modify_function, verify_change + (search-only arms all 0.00 in E3 -> try fast+quality union; + true graph stage needs in-memory SymbolIndex, unavailable cold) + +Metrics: klass-prediction accuracy vs GT klass, recall@5, facts coverage, +latency. Compare vs E3 baselines: fast 0.167 / quality 0.133 / cascade 0.233. +""" +import asyncio +import json +import statistics +import sys +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "experiments" / "mech_orch")) + +from E3_category_router_eval import ( # noqa: E402 + build_stack, run_one, gt_hit, fact_covered, snippets_of, LIMIT, TOP_K, + TASKS_FILE, OUT_FILE, +) + +OUT_ROUTER = PROJECT_ROOT / "experiments" / "mech_orch" / "results_E4_router.json" + +# ─── deterministic klass rules (keyword sets, checked in order) ───────────── +RULES = [ + ("git_history", ["история", " git", "commit", "кто изменял", "когда", "недавно", "кто и когда"]), + ("find_caller_callee", ["кто вызывает", "вызывае", "caller", "callee", "кого вызывает", "куда ведёт", "вызовы"]), + ("understand_architecture", ["архитектур", "слои", "как устроен", "объясни", "связ", "структур", "модул", "опиши", "оркестрация"]), + ("find_test", ["тест", "tests", "тестов"]), + ("find_impact", ["влия", "impact", "затронут", "слома", "затрагива"]), + ("verify_change", ["проверь", "верифициру", "безопасн", "пройдут", "подтверд", "риск"]), + ("modify_function", ["измен", "модифицир", "перепиши", "рефактор", "добавь", "напиши", "реализуй", "исправь", "дополни"]), + ("prepare_change", ["план", "подготов", "какие файлы", "оцени", "что менять", "приблизительно"]), + # fallback + ("find_bug_cause", ["почему", "причин", "баг", "ошибк", "падает", "не работает", "неверн", "пусто", "подозр", "de facto"]), +] + +ARM_BY_KLASS = { + "find_bug_cause": "fast", + "git_history": "fast", + "prepare_change": "fast", + "find_caller_callee": "quality", + "understand_architecture": "quality", + "find_test": "union", + "find_impact": "union", + "modify_function": "union", + "verify_change": "union", +} + + +def classify(prompt: str) -> str: + p = (prompt or "").lower() + for klass, kws in RULES: + for kw in kws: + if kw in p: + return klass + return "find_bug_cause" + + +def merge_files(res_a, res_b, topk=TOP_K): + out = [] + for r in (res_a + res_b): + meta = r.get("metadata") or {} + f = str(meta.get("file") or r.get("file") or "unknown") + if f not in out: + out.append(f) + return out[:topk] + + +async def main(): + tasks = json.loads(TASKS_FILE.read_text(encoding="utf-8"))["tasks"] + print(f"[*] router PoC: tasks={len(tasks)} topk={TOP_K} limit={LIMIT}") + searcher, db_path = build_stack() + + rows = [] + klass_hits = 0 + for t in tasks: + prompt = t.get("prompt") or "" + gt_klass = t.get("klass", "?") + pred_klass = classify(prompt) + if pred_klass == gt_klass: + klass_hits += 1 + arm = ARM_BY_KLASS.get(pred_klass, "fast") + + fast = await run_one(searcher, t, "fast") + if arm == "quality": + chosen = await run_one(searcher, t, "quality") + elif arm == "union": + quality = await run_one(searcher, t, "quality") + chosen = { + "files": merge_files(fast.get("results", []), quality.get("results", [])), + "latency_ms": fast.get("latency_ms", 0) + quality.get("latency_ms", 0), + "mode": "union", + "results": fast.get("results", []) + quality.get("results", []), + } + else: + chosen = fast + + hit = gt_hit(chosen.get("files", []), t.get("file", "")) + fcov, nf = fact_covered(t.get("required_facts", []), snippets_of(chosen.get("results", []))) + rows.append({ + "id": t["id"], "gt_klass": gt_klass, "pred_klass": pred_klass, + "arm": arm, "hit": hit, "latency_ms": chosen.get("latency_ms", 0), + "facts": fcov, "facts_n": nf, "files": chosen.get("files", [])[:3], + }) + print(f"{t['id']:4} {gt_klass:18}->{pred_klass:18} {arm:8} {'H' if hit else '-':1} " + f"{chosen.get('latency_ms', 0):7.0f}ms") + + n = len(rows) + recall = sum(r["hit"] for r in rows) / n + fcov_all = [r["facts"] / max(r["facts_n"], 1) for r in rows if r["facts_n"]] + lat = [r["latency_ms"] for r in rows] + by_klass = {} + for r in rows: + b = by_klass.setdefault(r["gt_klass"], {"n": 0, "hits": 0, "lat": []}) + b["n"] += 1 + b["hits"] += int(r["hit"]) + b["lat"].append(r["latency_ms"]) + for k, b in by_klass.items(): + b["recall"] = round(b["hits"] / b["n"], 3) + + report = { + "klass_accuracy": round(klass_hits / n, 3), + "recall_k": round(recall, 3), + "facts_coverage_mean": round(statistics.mean(fcov_all), 3) if fcov_all else None, + "latency_median_ms": round(statistics.median(lat), 1), + "latency_p95_ms": round(sorted(lat)[int(n * 0.95) - 1], 1) if n > 1 else None, + "by_klass": by_klass, + "rows": [{k: v for k, v in r.items()} for r in rows], + } + OUT_ROUTER.write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8") + + print("\n=== ROUTER vs E3 BASELINES ===") + print(f"router : recall={report['recall_k']} facts_cov={report['facts_coverage_mean']} " + f"lat_med={report['latency_median_ms']}ms p95={report['latency_p95_ms']}ms " + f"klass_acc={report['klass_accuracy']}") + print("E3 fast : recall=0.167 facts=0.517 lat_med=148ms p95=167ms") + print("E3 quality: recall=0.133 facts=0.861 lat_med=2145ms p95=6803ms") + print("E3 cascade: recall=0.233 facts=0.753 lat_med=564ms p95=6909ms") + print("\n=== BY KLASS ===") + for k, b in by_klass.items(): + print(f"{k:18}: n={b['n']:2} recall={b['recall']:.2f}") + print(f"\n[*] saved: {OUT_ROUTER}") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) \ No newline at end of file diff --git a/experiments/mech_orch/RESEARCH.md b/experiments/mech_orch/RESEARCH.md new file mode 100644 index 00000000..21284758 --- /dev/null +++ b/experiments/mech_orch/RESEARCH.md @@ -0,0 +1,42 @@ +# Mechanical Orchestration Without LLM — Research & Tool-Boundary Recommendation + +Status: 🟡 Research-only session (no src/ changes). Evidence in `EXPERIMENTS_LOG.md` (Exp M1–M3) and `AGENT_DIARY.md` 2026-08-25. + +## 1. Detective pass over `experiments/rehenie.md` (claim-by-claim) + +| Claim in rehenie.md | Verdict | Evidence | +|---|---|---| +| "roam-code by Dimitris Cranot, Apache 2.0" | ✅ real, ⚠️ URL wrong | Exists: `Cranot/roam-code` (511★, Python, Apache-2.0, pushed today). NOT `codeguidefx/roam-code` (404). | +| roam-code: 28 langs, task compiler, verify gates, Health Score, ChangeEvidence | ✅ CONFIRMED | README (GitHub API): 286 commands, 245 MCP tools (17 in default `core` preset), 8 presets, compiler A/B: −83% turns / −80% input tokens / −63% cost (41 cells), `ask` = deterministic router over 31-recipe registry, post-edit Stop-gate fail-closed, signed ChangeEvidence (HMAC run ledger). | +| "Agent does 8–15 calls → 1 call; ~11s → <0.5s; 15k → 3k tokens" | ✅ matches roam-code's published benchmark | Same numbers in roam-code README (Flask 200-file repro, "Tool calls 8→1, wall ~11s→<0.5s, tokens ~15k→~3k"). rehenie.md did not attribute them. | +| "Hide 85% of tools, show 5–8 meta-tools" | ❌ REFUTED | The market leader ships *more* tools than we do (245) and still wins — the mechanism is **presets + on-demand expansion + deterministic routing**, not hiding. arXiv 2605.24660: fixed-shortlist-5 loses to adaptive (87.1% vs 93.1%); depth must expand on hard queries (up to 50). | +| "Accuracy 85%→40% with 50+ tools"; "error 15%→60%" | 🟡 direction real, numbers unattributed | Tool-space interference is real (arXiv 2605.24660: decision accuracy degrades with shortlist size; medium queries 76.8% vs 60.9%). The exact percentages in rehenie.md are uncited — treat as illustrative, not measured. | +| "3,300+ calls to 14 LLMs, benchmark done" | ✅ CONFIRMED in-repo | `EXPERIMENTS_LOG.md` 2026-08-14/15/16: live-arms on OpenRouter across qwen3.5/3.6/3.7/3.8, glm-4.7/5.2, deepseek-v4 flash/pro, nemotron nano/lightning, claude-sonnet-5 — 3300+ calls, per-model recall/FA tables. | +| "30–70ms for 7 parallel passes" | ❌ NOT reproduced in our stack | Our real matrix: search_code(fast) 75ms ✓, search_code(quality) 3666ms ✗ (semantic miss), get_symbol_info "not found" on a real symbol (2nd miss/session), grep 28ms. roam-code compile ≈90ms is their measured number; ours is slower because vector path is heavy. | +| Aider repomap (PageRank on AST) | ✅ repomap real | Aider README: "Aider makes a map of your entire codebase", 100+ languages (ctags/tree-sitter); PageRank-style ranking per its repomap docs. | +| 4 blind spots (git blame, config/env, shadow coupling, token budget) | ✅ all real | Confirmed as gaps in our stack: no co-change matrix wired into search; token budget only in search limit; config/env not in context compile. | + +## 2. Real-data results (this session) + +- **Telemetry (M1):** 62 tools registered (32 core + 14 intel + 12 inline + 4 dev); server masks 16/32 by default; only 5 tools *ever* recorded metrics (8 calls, 3 errors — LSP toolkit 37.5% error rate, 15s timeouts). Long tail of dead tools is unmeasurable — no per-call counter logging. +- **Sub-agents (M2):** natural agent → **0/5 MCP calls** (list_directory/read/edit/terminal only); MCP-first agent → 9 calls, ~4 wasted on unindexed files (search×2, get_symbol_info, find_path misses); productive = passport/status + read_live_file×3. New files are invisible until reindex. +- **Latency matrix (M3):** fast 75ms ✓ vs quality 3666ms ✗ vs grep 28ms ✓ vs get_symbol_info ✗ "not found". + +## 3. Recommendation: the tool boundary (what to actually build) + +1. **Default visible set: ~8–12 tools** (job-shaped, not atom-shaped): one search (fast-first cascade), one read, one context-compile, one impact/verify, one runtime status, one change-preview. The remaining ~50 go into **presets / explicit `expand_toolset`** (roam-code pattern), not deletion. +2. **Mechanical cascade inside composite tools (no LLM):** exact (ripgrep/FTS) → LSP/disk read → vector only as last resort, with per-stage QoS budget (e.g. abandon vector >1.5s). On unindexed/fresh code → disk read + ripgrep with honest `source=disk` status (M2 failure). +3. **Deterministic intent router** over a recipe registry (~30 recipes, like roam `ask`): keyword/symbol-name/filename signals, tuned on our own 3300-call corpus — NOT an LLM classifier. +4. **Per-call tool telemetry** (counter per tool in the error_handler path) — the boundary is unmeasurable without it (M1). +5. **Fail-closed post-edit gates** (import firewall, delete-check, secrets) with symbol-keyed suppression (roam-code precedent) + gate-precision corpus in CI. +6. **Prompt/discoverability matters more than tool count:** M2 shows instruction about MCP flips usage 0→9. Ship a short "when to use which of the 8 tools" block in AGENTS.md/AGENT_DIARY deployments. + +## 4. Red Team on the proposal + +1. **Attack: fresh/unindexed target → semantic layer silently empty → agent wanders (measured in M2).** Defense: composite tools return `source=disk|index` and auto-fallback to ripgrep/diskk reads; a "reindex needed" hint with estimated cost; regression test = M2 scenario in CI. +2. **Attack: deterministic router misroutes query → wrong recipe.** Defense: cascade by cheap exact signals with a generic-context fallback recipe; keep a labeled misroute set (from 3300-call corpus) and re-run on every recipe change; never make routing fail-closed (a bad recipe is worse than a generic context). +3. **Attack: fan-out output bloat → 200k tokens (rehenie.md's own blind spot).** Defense: hard output budget + truncation policy (names-only beyond depth N) — token-budget test per composite tool; cap vector stage contribution. +4. **Attack: fail-closed gates reject good edits (precision loss).** Defense: symbol-keyed suppression file (like `.roam-suppressions`), gate-hit logging with weekly precision review; suppressor cannot alter gate policy/baselines. +5. **Attack: parallel agents × composite tool → shared LanceDB lock (real 2026-08-25 freeze incident).** Defense: keep reindex fast-fail + `asyncio.to_thread` in every new fan-out path; stress-test N=2 and N=8 concurrent compiles asserting *correct association* of results (caller A gets A's symbol), not just no-exception. + +Verdict: plan is workable with defenses 1–5; largest open risk is (2) routing precision and (3) token budget — both measurable before implementation. \ No newline at end of file diff --git a/experiments/mech_orch/lab/src/calc.py b/experiments/mech_orch/lab/src/calc.py new file mode 100644 index 00000000..fc8f06d3 --- /dev/null +++ b/experiments/mech_orch/lab/src/calc.py @@ -0,0 +1,33 @@ +"""Calculator core — small realistic module with a planted bug.""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Operation: + name: str + args: tuple + + +class Calculator: + def __init__(self, precision: int = 2) -> None: + self.precision = precision + + def add(self, a: float, b: float) -> float: + return round(a + b, self.precision) + + def sub(self, a: float, b: float) -> float: + return round(a - b, self.precision) + + def mul(self, a: float, b: float) -> float: + return round(a * b, self.precision) + + def div(self, a: float, b: float) -> float: + if b == 0: + raise ZeroDivisionError("division by zero") + return round(a / b, self.precision) + + def apply(self, op: Operation) -> float: + fn = getattr(self, op.name, None) + if fn is None: + raise ValueError(f"unknown operation: {op.name}") + return fn(*op.args) \ No newline at end of file diff --git a/experiments/mech_orch/lab/src/rpn.py b/experiments/mech_orch/lab/src/rpn.py new file mode 100644 index 00000000..6709f69b --- /dev/null +++ b/experiments/mech_orch/lab/src/rpn.py @@ -0,0 +1,23 @@ +"""RPN evaluator using Calculator via Operation dataclass.""" +from src.calc import Calculator, Operation + + +def evaluate(rpn: str) -> float: + calc = Calculator(precision=3) + stack: list[float] = [] + for token in rpn.split(): + if token == "+": + b, a = stack.pop(), stack.pop() + stack.append(calc.apply(Operation("add", (a, b)))) + elif token == "-": + b, a = stack.pop(), stack.pop() + stack.append(calc.apply(Operation("sub", (a, b)))) + elif token == "*": + b, a = stack.pop(), stack.pop() + stack.append(calc.apply(Operation("mul", (a, b)))) + elif token == "/": + b, a = stack.pop(), stack.pop() + stack.append(calc.apply(Operation("div", (a, b)))) + else: + stack.append(float(token)) + return stack[-1] \ No newline at end of file diff --git a/experiments/mech_orch/lab/tests/test_calc.py b/experiments/mech_orch/lab/tests/test_calc.py new file mode 100644 index 00000000..4bea3174 --- /dev/null +++ b/experiments/mech_orch/lab/tests/test_calc.py @@ -0,0 +1,28 @@ +"""Tests for the lab fixture. test_rpn_order_bug currently FAILS (planted bug).""" +import pytest + +from src.calc import Calculator, Operation +from src.rpn import evaluate + + +def test_add_ok() -> None: + assert Calculator().add(1, 2) == 3 + + +def test_div_zero_raises() -> None: + with pytest.raises(ZeroDivisionError): + Calculator().div(1, 0) + + +def test_apply_uses_operation() -> None: + assert Calculator().apply(Operation("mul", (3, 4))) == 12 + + +def test_rpn_order_bug() -> None: + # 5 2 - == 3 in RPN; planted bug: args are popped in reverse order -> -3 + result = evaluate("5 2 -") + assert result == 3, f"expected 3, got {result}" + + +def test_rpn_compound() -> None: + assert evaluate("2 3 * 4 +") == 10 \ No newline at end of file diff --git a/experiments/mech_orch/probe_e42_verify.py b/experiments/mech_orch/probe_e42_verify.py new file mode 100644 index 00000000..c68262ab --- /dev/null +++ b/experiments/mech_orch/probe_e42_verify.py @@ -0,0 +1,85 @@ +"""E4.2 probe — verify_change resolution fix against the REAL graph.db (no embedder). + +Avoids the heavy E4.1 live run (llama.cpp 8080 held by the live MCP): graph +navigation needs only the cold-start PropertyGraph (read-only), not the +embedder. Proves the two verify_change misses (T9/T29) now resolve to the +correct GT file via the concept resolver + graph definition lookup. + +Usage: python experiments/mech_orch/probe_e42_verify.py +""" +import json +import re +import sys +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from resolver import concept_symbol, graph_fact_text # noqa: E402 +from src.core.artifact_paths import get_graph_db_path # noqa: E402 +from src.core.graph import PropertyGraph # noqa: E402 +from src.core.search.graph_adapter import SymbolIndexAdapter # noqa: E402 + +TASKS = PROJECT_ROOT / "experiments" / "context_engine" / "tasks_v3.json" +WAIT_KLASS = {"verify_change"} + + +def norm(p: str) -> str: + return str(p).replace("\\", "/").lower() + + +def gt_hit(files, gt) -> bool: + g = norm(gt) + return any( + norm(f) == g or norm(f).endswith(g) or g.endswith(norm(f).split("/")[-1]) + for f in files or [] + ) + + +def main(): + tasks = json.loads(TASKS.read_text(encoding="utf-8"))["tasks"] + adapter = SymbolIndexAdapter( + PropertyGraph(get_graph_db_path(PROJECT_ROOT)), mode=SymbolIndexAdapter.MODE_PURE + ) + print(f"[*] graph symbols: {adapter.get_symbol_count() if hasattr(adapter, 'get_symbol_count') else 'n/a'}") + fail = 0 + for t in tasks: + if t.get("klass") not in WAIT_KLASS: + continue + prompt = t.get("prompt", "") + gt = t.get("file", "") + facts = t.get("required_facts", []) + sym = concept_symbol(prompt, t["klass"]) # deterministic, no embedder + if not sym: + print(f"{t['id']:4} verify_change NO-CONCEPT (still lexical case) -> {t['id']}") + fail += 1 + continue + try: + defs = adapter.find_definitions(sym) or [] + except Exception as e: + print(f"{t['id']:4} find_definitions err: {e}") + fail += 1 + continue + files = [getattr(r, "file_path", "") or "" for r in defs] + hit = gt_hit(files, gt) + blob = graph_fact_text(adapter, sym) + fc = sum(1 for f in facts if re.search(str(f.get("pattern", "") or ""), blob, re.IGNORECASE) + if str(f.get("pattern", "") or "")) + fc_n = len([f for f in facts if str(f.get("pattern", "") or "")]) + status = "HIT" if hit else "MISS" + if not hit: + fail += 1 + print(f"{t['id']:4} verify_change sym={sym:22} files={files} -> {status} gt={gt} facts={fc}/{fc_n}") + print(f"\n=== E4.2 graph-layer probe: verify_change {'ALL HIT' if fail == 0 else f'{fail} FAIL'} ===") + + +if __name__ == "__main__": + try: + main() + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/experiments/mech_orch/probe_fast_standalone.py b/experiments/mech_orch/probe_fast_standalone.py new file mode 100644 index 00000000..0033d30e --- /dev/null +++ b/experiments/mech_orch/probe_fast_standalone.py @@ -0,0 +1,54 @@ +"""Diagnostic probe: why does mode=fast return empty in standalone (E3 artifact).""" +import asyncio, json, os, sys, time +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +EXT = Path(os.getenv("EXT_ROOT", r"C:\Users\misha\AppData\Local\Zed\extensions\mscodebase-intelligence")) +if str(EXT) not in sys.path: + sys.path.insert(0, str(EXT)) +try: + from dotenv import load_dotenv + env = PROJECT_ROOT / ".env" + if env.exists(): + load_dotenv(env) +except ImportError: + pass +os.environ.setdefault("PYTHONPATH", str(EXT)) +os.environ["PROJECT_PATH"] = str(PROJECT_ROOT) +os.environ["LLAMA_CPP_ENABLED"] = "true" +import logging +logging.basicConfig(level=logging.DEBUG, stream=sys.stderr, format="%(name)s: %(message)s") +logging.getLogger("httpx").setLevel(logging.WARNING) + +from src.config.settings import get_config +from src.core.artifact_paths import get_db_path +from src.providers.embedder.remote_embedder import RemoteEmbedder +from src.core.indexing.file_guard import FileGuard +from src.core.indexing.indexer import Indexer +from src.core.search.engine import Searcher +from src.core.di_container import create_service_collection +from src.core.indexing.parser import CodeParser +from src.core.indexing.symbol_index import SymbolIndex + +async def main(): + services = create_service_collection(PROJECT_ROOT) + embedder = services.resolve(RemoteEmbedder) + db_path = get_db_path(PROJECT_ROOT) + indexer = Indexer(db_path=db_path, embedder=embedder, file_guard=FileGuard(PROJECT_ROOT), + project_path=PROJECT_ROOT, parser=CodeParser(), symbol_index=SymbolIndex()) + searcher = Searcher(indexer, embedder) + indexer.set_searcher(searcher) + n = indexer.table.count_rows() if indexer.table is not None else -1 + print("rows:", n, "dbm_is_reindexing:", indexer.db_manager.is_reindexing() if hasattr(indexer, 'db_manager') else "n/a") + for mode in ("fast", "quality"): + t0 = time.perf_counter() + out = searcher.search_with_mode("def hybrid_search", mode=mode, limit=5) + dt = (time.perf_counter() - t0) * 1000 + print(f"[{mode}] wall={dt:.0f}ms timing={json.dumps(out.get('timing_ms', {}))} n={len(out.get('results', []))}") + tr = out.get("trace") + if tr: + s = json.dumps(tr, default=str)[:600] + print(f"[{mode}] trace={s}") + cache_hit = out.get("cache_hit") + print(f"[{mode}] cache_hit={cache_hit} model={out.get('model_info')}") + +asyncio.run(main()) \ No newline at end of file diff --git a/experiments/mech_orch/resolver.py b/experiments/mech_orch/resolver.py new file mode 100644 index 00000000..d5a6af66 --- /dev/null +++ b/experiments/mech_orch/resolver.py @@ -0,0 +1,94 @@ +"""E4.2 — deterministic concept→symbol resolver (mechanical, no LLM). + +Extends E4.1's lexical extract_symbol with a concept-phrase registry consulted +BEFORE lexical extraction. Fixes the two "wordless" verify_change failures +where the target is described by consequence/concept rather than by symbol name: + - T9 : "какой инструмент … для обновления индекса" -> notify_change + (lexical pulled "engine" — wrong-anchor) + - T29 : "паттерны извлечения" -> _extract_symbol_name + (no lexical anchor at all -> cascade fallback) + +Table lookup is intentionally not an LLM classifier — it matches the mechanical +intent-router recommendation (RESEARCH.md rec #3): deterministic recipe registry, +fail-open (no hit -> caller falls back to lexical extraction). + +Seed set covers the E4.1 verify_change failures; mechanism generalizes. +Corpus-tuned seeding from the 3300-call corpus (RESEARCH.md rec 3) = follow-up. +""" +from pathlib import Path + +# (phrases, klass, symbol). klass=None => applies to any klass. +# Checked in order; first matching recipe wins. Phrases matched as lowercase +# substrings (more robust than regex on Russian morphology for fixed phrases). +CONCEPT_RECIPES = [ + { + "phrases": [ + "инструмент для обновления индекса", + "обновления индекса", + "обновление индекса", + "обновить индекс", + ], + "klass": "verify_change", + "symbol": "notify_change", + }, + { + "phrases": [ + "паттерны извлечения", + "паттернов извлечения", + "паттерн извлечения", + "извлечения паттерн", + "паттерны извлечений", + ], + "klass": "verify_change", + "symbol": "_extract_symbol_name", + }, +] + + +def concept_symbol(prompt: str, klass: str) -> str: + """Return the concept-mapped symbol for a prompt+klass, or "" if no recipe + hits. Fail-open: caller falls back to lexical extract_symbol. + """ + if not prompt: + return "" + low = " ".join(prompt.lower().split()) + for recipe in CONCEPT_RECIPES: + if recipe["klass"] and recipe["klass"] != klass: + continue + for phrase in recipe["phrases"]: + if phrase in low: + return recipe["symbol"] + return "" + + +def read_snippet(file_path, line, before=3, after=9): + """Read a small source window around `line` (1-based). '' on error/absent.""" + try: + fp = Path(file_path) + if not fp.exists(): + return "" + lines = fp.read_text(encoding="utf-8", errors="replace").splitlines() + lo = max(0, int(line) - 1 - before) + hi = min(len(lines), int(line) - 1 + after) + return "\n".join(lines[lo:hi]) + except Exception: # noqa: BLE001 — fail-open by design: snippet read must never break resolution + return "" + + +def graph_fact_text(adapter, sym: str) -> str: + """Gather definition file paths + source snippets for `sym` into a text blob + to run required_facts coverage against (Option V: graph rows carry evidence, + not just file paths). Adapter.definitions expose file_path+line only.""" + parts = [] + try: + defs = adapter.find_definitions(sym) or [] + except Exception: # noqa: BLE001 — fail-open by design: unknown adapter surface + defs = [] + for r in defs: + fp = getattr(r, "file_path", "") or "" + line = getattr(r, "line", 0) or 0 + if fp: + parts.append(fp) + if fp and line: + parts.append(read_snippet(fp, line)) + return "\n".join(p for p in parts if p) diff --git a/experiments/mech_orch/results_E3_router.json b/experiments/mech_orch/results_E3_router.json new file mode 100644 index 00000000..fcbd9ffe --- /dev/null +++ b/experiments/mech_orch/results_E3_router.json @@ -0,0 +1,1363 @@ +{ + "topk": 5, + "n": 30, + "arms": { + "fast": { + "recall_k": 0.167, + "facts_coverage_mean": 0.517, + "latency_median_ms": 148.1, + "latency_p95_ms": 166.6 + }, + "quality": { + "recall_k": 0.133, + "facts_coverage_mean": 0.861, + "latency_median_ms": 2145.3, + "latency_p95_ms": 6802.7 + }, + "cascade": { + "recall_k": 0.233, + "facts_coverage_mean": 0.753, + "latency_median_ms": 564.1, + "latency_p95_ms": 6909.3 + } + }, + "by_klass": { + "find_bug_cause": { + "n": 5, + "fast_recall": 0.2, + "quality_recall": 0.0, + "cascade_recall": 0.2 + }, + "find_caller_callee": { + "n": 2, + "fast_recall": 0.0, + "quality_recall": 0.5, + "cascade_recall": 0.5 + }, + "find_impact": { + "n": 3, + "fast_recall": 0.0, + "quality_recall": 0.0, + "cascade_recall": 0.0 + }, + "find_test": { + "n": 2, + "fast_recall": 0.0, + "quality_recall": 0.0, + "cascade_recall": 0.0 + }, + "git_history": { + "n": 4, + "fast_recall": 0.5, + "quality_recall": 0.25, + "cascade_recall": 0.5 + }, + "modify_function": { + "n": 4, + "fast_recall": 0.0, + "quality_recall": 0.0, + "cascade_recall": 0.0 + }, + "prepare_change": { + "n": 4, + "fast_recall": 0.25, + "quality_recall": 0.0, + "cascade_recall": 0.25 + }, + "understand_architecture": { + "n": 4, + "fast_recall": 0.25, + "quality_recall": 0.5, + "cascade_recall": 0.5 + }, + "verify_change": { + "n": 2, + "fast_recall": 0.0, + "quality_recall": 0.0, + "cascade_recall": 0.0 + } + }, + "rows": [ + { + "id": "T1", + "klass": "find_bug_cause", + "gt": "src/core/indexing/symbol_index.py", + "fast": { + "mode": "fast", + "latency_ms": 4383.787799975835, + "server_total_ms": 4383.734500035644, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_v2.json", + "src\\core\\relation_extractor.py", + "experiments\\context_engine\\results_tasks_v3.json", + "src\\core\\graph_rag.py", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 9763.495000021067, + "server_total_ms": 9763.468199991621, + "n_results": 5, + "files": [ + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\strategy_a_data.json", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\results_tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 14147.282799996901, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T2", + "klass": "modify_function", + "gt": "src/core/intelligence/layer.py", + "fast": { + "mode": "fast", + "latency_ms": 164.10490003181621, + "server_total_ms": 164.03380001429468, + "n_results": 8, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_v2.json", + "KNOWN_ISSUES.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\ARCHITECTURE_LAYERS.md" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 331.39519998803735, + "server_total_ms": 331.38019999023527, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\tasks.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 495.50010001985356, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T3", + "klass": "find_impact", + "gt": "src/core/search/engine.py", + "fast": { + "mode": "fast", + "latency_ms": 130.4492999915965, + "server_total_ms": 130.40110003203154, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_v2.json", + "src\\core\\graph_rag.py", + "experiments\\context_engine\\results_tasks_v3.json", + "src\\core\\graph_rag.py", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 2, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 6289.31849997025, + "server_total_ms": 6289.305299986154, + "n_results": 3, + "files": [ + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\results_v2.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 6419.767799961846, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T4", + "klass": "understand_architecture", + "gt": "src/core/search/engine.py", + "fast": { + "mode": "fast", + "latency_ms": 152.17749995645136, + "server_total_ms": 152.11760002421215, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_v2.json", + "src\\core\\search\\engine.py", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\results_tasks_v3.json" + ], + "hit": true, + "facts": 2, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 365.8436000114307, + "server_total_ms": 365.83070002961904, + "n_results": 8, + "files": [ + "src\\core\\search\\graph_adapter.py", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\tasks_v2.json", + "src\\core\\search\\engine.py", + "experiments\\context_engine\\results_tasks_v3.json" + ], + "hit": true, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": true, + "mode": "fast", + "latency_ms": 152.17749995645136, + "facts": 2, + "facts_n": 4 + } + }, + { + "id": "T5", + "klass": "find_test", + "gt": "src/core/intelligence/tools_reg.py", + "fast": { + "mode": "fast", + "latency_ms": 148.99450005032122, + "server_total_ms": 148.94029998686165, + "n_results": 8, + "files": [ + "docs\\ru\\SEARCH_PIPELINE.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_v2.json", + "docs\\ru\\GRACEFUL_DEGRADATION.md" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 6149.516100005712, + "server_total_ms": 6149.503100023139, + "n_results": 5, + "files": [ + "experiments\\context_engine\\tasks.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\results_tasks_v3.json" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 6298.510600056034, + "facts": 3, + "facts_n": 4 + } + }, + { + "id": "T6", + "klass": "git_history", + "gt": "src/core/intelligence/layer.py", + "fast": { + "mode": "fast", + "latency_ms": 159.14970001904294, + "server_total_ms": 159.09349999856204, + "n_results": 8, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\archive\\DEV_DIARY_2026_07.md", + "experiments\\context_engine\\tasks_v2.json", + "docs\\ru\\FAQ.md" + ], + "hit": false, + "facts": 0, + "facts_n": 3 + }, + "quality": { + "mode": "quality", + "latency_ms": 373.5336000099778, + "server_total_ms": 373.5210999730043, + "n_results": 8, + "files": [ + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json", + "src\\utils\\__init__.py", + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 3, + "facts_n": 3 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 532.6833000290208, + "facts": 3, + "facts_n": 3 + } + }, + { + "id": "T7", + "klass": "find_caller_callee", + "gt": "src/core/indexing/symbol_index.py", + "fast": { + "mode": "fast", + "latency_ms": 129.73829999100417, + "server_total_ms": 129.68750001164153, + "n_results": 8, + "files": [ + "KNOWN_ISSUES.md", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\results_tasks_v3.json", + "src\\core\\search\\graph_adapter.py", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 5300.28049997054, + "server_total_ms": 5300.267899991013, + "n_results": 3, + "files": [ + "experiments\\context_engine\\results_v2.json", + "src\\core\\indexing\\symbol_index.py", + "experiments\\context_engine\\results_tasks_v3.json" + ], + "hit": true, + "facts": 0, + "facts_n": 4 + }, + "cascade": { + "hit": true, + "mode": "quality", + "latency_ms": 5430.018799961545, + "facts": 0, + "facts_n": 4 + } + }, + { + "id": "T8", + "klass": "prepare_change", + "gt": "src/core/intelligence/layer.py", + "fast": { + "mode": "fast", + "latency_ms": 167.0025999774225, + "server_total_ms": 166.94479994475842, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_v2.json", + "src\\sources\\__init__.py", + "experiments\\context_engine\\results_tasks_v3.json", + "src\\core\\relation_extractor.py", + "experiments\\context_engine\\tasks_v2.json" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 377.2033999557607, + "server_total_ms": 377.19120003748685, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "src\\sources\\__init__.py", + "experiments\\context_engine\\tasks_v2.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 544.2059999331832, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T9", + "klass": "verify_change", + "gt": "src/mcp/server_tools.py", + "fast": { + "mode": "fast", + "latency_ms": 147.6884000003338, + "server_total_ms": 147.64069998636842, + "n_results": 8, + "files": [ + "experiments\\context_engine\\tasks_v3.json", + "experiments\\rehenie.md", + "experiments\\context_engine\\tasks_v2.json", + "experiments\\universal-engine\\E07_RESULTS.md", + "ISSUE.md" + ], + "hit": false, + "facts": 1, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 5553.098299948033, + "server_total_ms": 5553.084900020622, + "n_results": 2, + "files": [ + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 5700.786699948367, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T10", + "klass": "find_bug_cause", + "gt": "src/core/indexing/db_writer.py", + "fast": { + "mode": "fast", + "latency_ms": 153.24370004236698, + "server_total_ms": 153.16739998525009, + "n_results": 8, + "files": [ + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_v2.json", + "docs\\ru\\GRACEFUL_DEGRADATION.md" + ], + "hit": false, + "facts": 1, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 309.25930000375956, + "server_total_ms": 309.2474000295624, + "n_results": 8, + "files": [ + "experiments\\1V_memory_contamination\\memory_contamination_facts_v4_rep_corrected.json", + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\results_v2.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 462.50300004612654, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T11", + "klass": "understand_architecture", + "gt": "src/mcp/tools/context_tool.py", + "fast": { + "mode": "fast", + "latency_ms": 121.67199997929856, + "server_total_ms": 121.61159998504445, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "src\\core\\eta_predictor.py", + "experiments\\context_engine\\results_v2.json", + "src\\core\\runtime_coordinator.py", + "experiments\\context_engine\\results_v2.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 5189.495500002522, + "server_total_ms": 5189.478800049983, + "n_results": 2, + "files": [ + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 5311.167499981821, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T12", + "klass": "modify_function", + "gt": "src/core/search/utils.py", + "fast": { + "mode": "fast", + "latency_ms": 154.42899998743087, + "server_total_ms": 154.36720004072413, + "n_results": 8, + "files": [ + "docs\\ru\\ARCHITECTURE_LAYERS.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md", + "scripts\\verify_diary.py", + "docs\\ru\\TELEMETRY.md" + ], + "hit": false, + "facts": 0, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 310.4318999685347, + "server_total_ms": 310.420099995099, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "scripts\\verify_diary.py" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 464.8608999559656, + "facts": 3, + "facts_n": 4 + } + }, + { + "id": "T13", + "klass": "find_test", + "gt": "src/mcp/tools/search_tools.py", + "fast": { + "mode": "fast", + "latency_ms": 111.10480001661927, + "server_total_ms": 111.03969998657703, + "n_results": 8, + "files": [ + "docs\\en\\CHANGELOG.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\BENCHMARK.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\CHANGELOG.md" + ], + "hit": false, + "facts": 2, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 5787.595300003886, + "server_total_ms": 5787.581300013699, + "n_results": 2, + "files": [ + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 2, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 5898.7001000205055, + "facts": 2, + "facts_n": 4 + } + }, + { + "id": "T14", + "klass": "git_history", + "gt": "src/core/indexing/symbol_index.py", + "fast": { + "mode": "fast", + "latency_ms": 153.63869996508583, + "server_total_ms": 153.57480000238866, + "n_results": 8, + "files": [ + "scripts\\architecture_linter.py", + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\FAQ.md", + "src\\core\\indexing\\symbol_index.py" + ], + "hit": true, + "facts": 0, + "facts_n": 2 + }, + "quality": { + "mode": "quality", + "latency_ms": 359.74570002872497, + "server_total_ms": 359.7326999879442, + "n_results": 8, + "files": [ + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\tasks_v2.json", + "scripts\\architecture_linter.py", + "experiments\\context_engine\\tasks_v3.json", + "src\\core\\indexing\\symbol_index.py" + ], + "hit": true, + "facts": 2, + "facts_n": 2 + }, + "cascade": { + "hit": true, + "mode": "fast", + "latency_ms": 153.63869996508583, + "facts": 0, + "facts_n": 2 + } + }, + { + "id": "T15", + "klass": "prepare_change", + "gt": "src/providers/reranker/llama_runner.py", + "fast": { + "mode": "fast", + "latency_ms": 136.78930001333356, + "server_total_ms": 136.71749999048188, + "n_results": 8, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\LM_STUDIO_SETUP.md", + "src\\providers\\reranker\\llama_runner.py", + "src\\providers\\reranker\\llama_runner.py" + ], + "hit": true, + "facts": 1, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 5650.110999995377, + "server_total_ms": 5650.0973999500275, + "n_results": 2, + "files": [ + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": true, + "mode": "fast", + "latency_ms": 136.78930001333356, + "facts": 1, + "facts_n": 4 + } + }, + { + "id": "T16", + "klass": "find_bug_cause", + "gt": "src/mcp/tools/search_tools.py", + "fast": { + "mode": "fast", + "latency_ms": 160.02459998708218, + "server_total_ms": 159.95679999468848, + "n_results": 8, + "files": [ + "docs\\en\\CHANGELOG.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\CHANGELOG.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\FAQ.md" + ], + "hit": false, + "facts": 2, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 332.78619998600334, + "server_total_ms": 332.77369994902983, + "n_results": 8, + "files": [ + "experiments\\context_engine\\strategy_a_data_v2.json", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\tasks_v3.json", + "src\\core\\search\\engine.py", + "experiments\\context_engine\\results_tasks_v3.json" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 492.8107999730855, + "facts": 3, + "facts_n": 4 + } + }, + { + "id": "T17", + "klass": "modify_function", + "gt": "src/core/search/engine.py", + "fast": { + "mode": "fast", + "latency_ms": 142.27010001195595, + "server_total_ms": 142.2076000017114, + "n_results": 8, + "files": [ + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md", + "src\\core\\indexing\\parser.py", + "docs\\ru\\SECURITY.md" + ], + "hit": false, + "facts": 0, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 5426.475499989465, + "server_total_ms": 5426.462500006892, + "n_results": 1, + "files": [ + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 5568.745600001421, + "facts": 3, + "facts_n": 4 + } + }, + { + "id": "T18", + "klass": "find_impact", + "gt": "src/mcp/server_tools.py", + "fast": { + "mode": "fast", + "latency_ms": 145.0493999873288, + "server_total_ms": 144.9853999656625, + "n_results": 8, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_tasks_v3.json", + "KNOWN_ISSUES.md", + "experiments\\context_engine\\results_v2.json", + "docs\\ru\\SECURITY.md" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 356.625999964308, + "server_total_ms": 356.6123000346124, + "n_results": 7, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 501.6753999516368, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T19", + "klass": "understand_architecture", + "gt": "src/core/intelligence/layer.py", + "fast": { + "mode": "fast", + "latency_ms": 149.6023999643512, + "server_total_ms": 149.5395999518223, + "n_results": 8, + "files": [ + "docs\\ru\\TELEMETRY.md", + "experiments\\context_engine\\results_v2.json", + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\CONTRIBUTING.md" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 6545.70259997854, + "server_total_ms": 6545.68979999749, + "n_results": 2, + "files": [ + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\results_tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 6695.304999942891, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T20", + "klass": "git_history", + "gt": "src/core/indexing/db_writer.py", + "fast": { + "mode": "fast", + "latency_ms": 148.38480000616983, + "server_total_ms": 148.32360000582412, + "n_results": 8, + "files": [ + "scripts\\architecture_linter.py", + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\FAQ.md", + "src\\core\\indexing\\db_writer.py" + ], + "hit": true, + "facts": 0, + "facts_n": 3 + }, + "quality": { + "mode": "quality", + "latency_ms": 305.9301999746822, + "server_total_ms": 305.91969995293766, + "n_results": 8, + "files": [ + "scripts\\architecture_linter.py", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "src\\utils\\__init__.py", + "experiments\\context_engine\\results_v2.json" + ], + "hit": false, + "facts": 3, + "facts_n": 3 + }, + "cascade": { + "hit": true, + "mode": "fast", + "latency_ms": 148.38480000616983, + "facts": 0, + "facts_n": 3 + } + }, + { + "id": "T21", + "klass": "find_caller_callee", + "gt": "src/mcp/server_tools.py", + "fast": { + "mode": "fast", + "latency_ms": 106.58369999146089, + "server_total_ms": 106.54070001328364, + "n_results": 8, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_tasks_v3.json", + "KNOWN_ISSUES.md", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\results_tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 6802.669800003059, + "server_total_ms": 6802.655900013633, + "n_results": 1, + "files": [ + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 6909.25349999452, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T22", + "klass": "modify_function", + "gt": "src/core/intelligence/layer.py", + "fast": { + "mode": "fast", + "latency_ms": 147.8400999912992, + "server_total_ms": 147.77260000118986, + "n_results": 8, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\rehenie.md", + "docs\\en\\investigations\\LSP_WONTFIX.md" + ], + "hit": false, + "facts": 2, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 329.5055999769829, + "server_total_ms": 329.49390000430867, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\tasks_v2.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 477.34569996828213, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T23", + "klass": "find_bug_cause", + "gt": "src/core/intelligence/layer.py", + "fast": { + "mode": "fast", + "latency_ms": 127.79550004051998, + "server_total_ms": 127.74219998391345, + "n_results": 7, + "files": [ + "experiments\\1V_memory_contamination\\memory_contamination_facts_v4_rep_corrected.json", + "experiments\\context_engine\\results_tasks_v3.json", + "src\\core\\intelligence\\layer.py", + "src\\utils\\__init__.py", + "experiments\\context_engine\\results_tasks_v3.json" + ], + "hit": true, + "facts": 4, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 5023.658299993258, + "server_total_ms": 5023.648899979889, + "n_results": 4, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\strategy_a_data_v3_extra.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": true, + "mode": "fast", + "latency_ms": 127.79550004051998, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T24", + "klass": "understand_architecture", + "gt": "src/core/runtime_coordinator.py", + "fast": { + "mode": "fast", + "latency_ms": 90.66210000310093, + "server_total_ms": 90.59519995935261, + "n_results": 8, + "files": [ + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\ARCHITECTURE_LAYERS.md" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 239.88539999118075, + "server_total_ms": 239.87649998161942, + "n_results": 7, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\strategy_a_data_v3_extra.json", + "src\\core\\runtime_coordinator.py", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": true, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": true, + "mode": "quality", + "latency_ms": 330.5474999942817, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T25", + "klass": "find_bug_cause", + "gt": "src/core/project_resolution.py", + "fast": { + "mode": "fast", + "latency_ms": 91.80860000196844, + "server_total_ms": 91.75259998301044, + "n_results": 8, + "files": [ + "experiments\\context_engine\\tasks_v3.json", + "experiments\\research_verification_layers_devto_2026-08-11.md", + "docs\\ru\\FAQ.md", + "src\\core\\indexing\\index_guard.py", + "docs\\en\\CHANGELOG.md" + ], + "hit": false, + "facts": 0, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 3872.92430002708, + "server_total_ms": 3872.9108999832533, + "n_results": 1, + "files": [ + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 3964.7329000290483, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T26", + "klass": "git_history", + "gt": "src/mcp/tools/search_tools.py", + "fast": { + "mode": "fast", + "latency_ms": 158.24339998653159, + "server_total_ms": 158.16709998762235, + "n_results": 8, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\FAQ.md" + ], + "hit": false, + "facts": 0, + "facts_n": 3 + }, + "quality": { + "mode": "quality", + "latency_ms": 398.0153000447899, + "server_total_ms": 398.00049999030307, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "src\\utils\\__init__.py", + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 1, + "facts_n": 3 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 556.2587000313215, + "facts": 1, + "facts_n": 3 + } + }, + { + "id": "T27", + "klass": "find_impact", + "gt": "src/core/intelligence/layer.py", + "fast": { + "mode": "fast", + "latency_ms": 134.63849999243394, + "server_total_ms": 134.56900004530326, + "n_results": 8, + "files": [ + "docs\\BENCHMARK.md", + "experiments\\context_engine\\results_v2.json", + "experiments\\2E_evidence_ladder\\temporal_contexts_e8571628.json", + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\1V_memory_contamination\\memory_contamination_facts_v4_rep_corrected.json" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 7281.572400010191, + "server_total_ms": 7281.557900016196, + "n_results": 2, + "files": [ + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 1, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 7416.210900002625, + "facts": 1, + "facts_n": 4 + } + }, + { + "id": "T28", + "klass": "prepare_change", + "gt": "src/core/indexing/db_writer.py", + "fast": { + "mode": "fast", + "latency_ms": 158.42609998071566, + "server_total_ms": 158.33860001293942, + "n_results": 8, + "files": [ + "docs\\ru\\TELEMETRY.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\CHANGELOG.md", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 2, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 417.6177000044845, + "server_total_ms": 417.6015999983065, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\strategy_a_data_v2.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\root_cause_eval\\incident_dataset_full.json" + ], + "hit": false, + "facts": 4, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 576.0437999852002, + "facts": 4, + "facts_n": 4 + } + }, + { + "id": "T29", + "klass": "verify_change", + "gt": "src/core/search/utils.py", + "fast": { + "mode": "fast", + "latency_ms": 136.66690001264215, + "server_total_ms": 136.56970002921298, + "n_results": 8, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\SECURITY.md", + "src\\core\\structural_search.py", + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md" + ], + "hit": false, + "facts": 0, + "facts_n": 4 + }, + "quality": { + "mode": "quality", + "latency_ms": 5678.609800001141, + "server_total_ms": 5678.59679996036, + "n_results": 1, + "files": [ + "experiments\\context_engine\\tasks_v3.json" + ], + "hit": false, + "facts": 3, + "facts_n": 4 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 5815.276700013783, + "facts": 3, + "facts_n": 4 + } + }, + { + "id": "T30", + "klass": "prepare_change", + "gt": "src/mcp/tools/context_tool.py", + "fast": { + "mode": "fast", + "latency_ms": 166.60870000487193, + "server_total_ms": 166.52570001315325, + "n_results": 8, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_tasks_v3.json", + "KNOWN_ISSUES.md", + "experiments\\context_engine\\results_v2.json", + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md" + ], + "hit": false, + "facts": 5, + "facts_n": 5 + }, + "quality": { + "mode": "quality", + "latency_ms": 405.3698999923654, + "server_total_ms": 405.35559999989346, + "n_results": 8, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json", + "src\\core\\eta_predictor.py", + "experiments\\1V_memory_contamination\\memory_contamination_facts_v4_rep_corrected.json", + "experiments\\context_engine\\results_v2.json" + ], + "hit": false, + "facts": 5, + "facts_n": 5 + }, + "cascade": { + "hit": false, + "mode": "quality", + "latency_ms": 571.9785999972373, + "facts": 5, + "facts_n": 5 + } + } + ] +} \ No newline at end of file diff --git a/experiments/mech_orch/results_E4_1_graph.json b/experiments/mech_orch/results_E4_1_graph.json new file mode 100644 index 00000000..057e3dcf --- /dev/null +++ b/experiments/mech_orch/results_E4_1_graph.json @@ -0,0 +1,414 @@ +{ + "recall_k": 0.5, + "recall_cascade_samrun": 0.267, + "latency_median_ms": 196.8, + "latency_p95_ms": 4514.1, + "by_klass": { + "find_bug_cause": { + "n": 5, + "hits": 1, + "casc_hits": 1, + "recall_graph": 0.2, + "recall_cascade": 0.2 + }, + "modify_function": { + "n": 4, + "hits": 1, + "casc_hits": 0, + "recall_graph": 0.25, + "recall_cascade": 0.0 + }, + "find_impact": { + "n": 3, + "hits": 3, + "casc_hits": 0, + "recall_graph": 1.0, + "recall_cascade": 0.0 + }, + "understand_architecture": { + "n": 4, + "hits": 1, + "casc_hits": 1, + "recall_graph": 0.25, + "recall_cascade": 0.25 + }, + "find_test": { + "n": 2, + "hits": 1, + "casc_hits": 0, + "recall_graph": 0.5, + "recall_cascade": 0.0 + }, + "git_history": { + "n": 4, + "hits": 4, + "casc_hits": 4, + "recall_graph": 1.0, + "recall_cascade": 1.0 + }, + "find_caller_callee": { + "n": 2, + "hits": 1, + "casc_hits": 1, + "recall_graph": 0.5, + "recall_cascade": 0.5 + }, + "prepare_change": { + "n": 4, + "hits": 1, + "casc_hits": 1, + "recall_graph": 0.25, + "recall_cascade": 0.25 + }, + "verify_change": { + "n": 2, + "hits": 2, + "casc_hits": 0, + "recall_graph": 1.0, + "recall_cascade": 0.0 + } + }, + "rows": [ + { + "id": "T1", + "klass": "find_bug_cause", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 10151.0, + "gt": "src/core/indexing/symbol_index.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T2", + "klass": "modify_function", + "arm": "graph:intel_code_topology", + "hit": true, + "cascade_hit": false, + "latency_ms": 15.0, + "cascade_latency_ms": 239.2, + "gt": "src/core/intelligence/layer.py", + "facts": 1, + "facts_n": 4 + }, + { + "id": "T3", + "klass": "find_impact", + "arm": "graph:_expand_graph_context", + "hit": true, + "cascade_hit": false, + "latency_ms": 15.0, + "cascade_latency_ms": 4881.1, + "gt": "src/core/search/engine.py", + "facts": 2, + "facts_n": 4 + }, + { + "id": "T4", + "klass": "understand_architecture", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 197.4, + "gt": "src/core/search/engine.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T5", + "klass": "find_test", + "arm": "graph:trigger_reindex", + "hit": true, + "cascade_hit": false, + "latency_ms": 15.0, + "cascade_latency_ms": 4733.2, + "gt": "src/core/intelligence/tools_reg.py", + "facts": 1, + "facts_n": 4 + }, + { + "id": "T6", + "klass": "git_history", + "arm": "fast+quality", + "hit": true, + "cascade_hit": true, + "latency_ms": 201.0, + "gt": "src/core/intelligence/layer.py", + "facts": 3, + "facts_n": 3 + }, + { + "id": "T7", + "klass": "find_caller_callee", + "arm": "fast+quality", + "hit": true, + "cascade_hit": true, + "latency_ms": 4514.1, + "gt": "src/core/indexing/symbol_index.py", + "facts": 0, + "facts_n": 4 + }, + { + "id": "T8", + "klass": "prepare_change", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 241.9, + "gt": "src/core/intelligence/layer.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T9", + "klass": "verify_change", + "arm": "graph:notify_change", + "hit": true, + "cascade_hit": false, + "latency_ms": 15.0, + "cascade_latency_ms": 5634.1, + "gt": "src/mcp/server_tools.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T10", + "klass": "find_bug_cause", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 216.1, + "gt": "src/core/indexing/db_writer.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T11", + "klass": "understand_architecture", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 4742.2, + "gt": "src/mcp/tools/context_tool.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T12", + "klass": "modify_function", + "arm": "graph:fallback->cascade", + "hit": false, + "cascade_hit": false, + "latency_ms": 5.0, + "cascade_latency_ms": 210.8, + "gt": "src/core/search/utils.py", + "facts": 0, + "facts_n": 4 + }, + { + "id": "T13", + "klass": "find_test", + "arm": "graph:search_code", + "hit": false, + "cascade_hit": false, + "latency_ms": 15.0, + "cascade_latency_ms": 4598.1, + "gt": "src/mcp/tools/search_tools.py", + "facts": 1, + "facts_n": 4 + }, + { + "id": "T14", + "klass": "git_history", + "arm": "fast+quality", + "hit": true, + "cascade_hit": true, + "latency_ms": 194.6, + "gt": "src/core/indexing/symbol_index.py", + "facts": 2, + "facts_n": 2 + }, + { + "id": "T15", + "klass": "prepare_change", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 3850.8, + "gt": "src/providers/reranker/llama_runner.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T16", + "klass": "find_bug_cause", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 196.1, + "gt": "src/mcp/tools/search_tools.py", + "facts": 3, + "facts_n": 4 + }, + { + "id": "T17", + "klass": "modify_function", + "arm": "graph:fallback->cascade", + "hit": false, + "cascade_hit": false, + "latency_ms": 5.0, + "cascade_latency_ms": 3361.3, + "gt": "src/core/search/engine.py", + "facts": 0, + "facts_n": 4 + }, + { + "id": "T18", + "klass": "find_impact", + "arm": "graph:notify_change", + "hit": true, + "cascade_hit": false, + "latency_ms": 15.0, + "cascade_latency_ms": 189.7, + "gt": "src/mcp/server_tools.py", + "facts": 3, + "facts_n": 4 + }, + { + "id": "T19", + "klass": "understand_architecture", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 4293.1, + "gt": "src/core/intelligence/layer.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T20", + "klass": "git_history", + "arm": "fast+quality", + "hit": true, + "cascade_hit": true, + "latency_ms": 202.2, + "gt": "src/core/indexing/db_writer.py", + "facts": 3, + "facts_n": 3 + }, + { + "id": "T21", + "klass": "find_caller_callee", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 4124.0, + "gt": "src/mcp/server_tools.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T22", + "klass": "modify_function", + "arm": "graph:reindex", + "hit": false, + "cascade_hit": false, + "latency_ms": 15.0, + "cascade_latency_ms": 194.2, + "gt": "src/core/intelligence/layer.py", + "facts": 0, + "facts_n": 4 + }, + { + "id": "T23", + "klass": "find_bug_cause", + "arm": "fast+quality", + "hit": true, + "cascade_hit": true, + "latency_ms": 4428.3, + "gt": "src/core/intelligence/layer.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T24", + "klass": "understand_architecture", + "arm": "fast+quality", + "hit": true, + "cascade_hit": true, + "latency_ms": 198.4, + "gt": "src/core/runtime_coordinator.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T25", + "klass": "find_bug_cause", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 3166.0, + "gt": "src/core/project_resolution.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T26", + "klass": "git_history", + "arm": "fast+quality", + "hit": true, + "cascade_hit": true, + "latency_ms": 198.2, + "gt": "src/mcp/tools/search_tools.py", + "facts": 1, + "facts_n": 3 + }, + { + "id": "T27", + "klass": "find_impact", + "arm": "graph:intel_code_topology", + "hit": true, + "cascade_hit": false, + "latency_ms": 15.0, + "cascade_latency_ms": 4305.6, + "gt": "src/core/intelligence/layer.py", + "facts": 1, + "facts_n": 4 + }, + { + "id": "T28", + "klass": "prepare_change", + "arm": "fast+quality", + "hit": true, + "cascade_hit": true, + "latency_ms": 188.1, + "gt": "src/core/indexing/db_writer.py", + "facts": 4, + "facts_n": 4 + }, + { + "id": "T29", + "klass": "verify_change", + "arm": "graph:_extract_symbol_name", + "hit": true, + "cascade_hit": false, + "latency_ms": 15.0, + "cascade_latency_ms": 3347.6, + "gt": "src/core/search/utils.py", + "facts": 3, + "facts_n": 4 + }, + { + "id": "T30", + "klass": "prepare_change", + "arm": "fast+quality", + "hit": false, + "cascade_hit": false, + "latency_ms": 163.5, + "gt": "src/mcp/tools/context_tool.py", + "facts": 5, + "facts_n": 5 + } + ] +} \ No newline at end of file diff --git a/experiments/mech_orch/results_E4_router.json b/experiments/mech_orch/results_E4_router.json new file mode 100644 index 00000000..df10cb15 --- /dev/null +++ b/experiments/mech_orch/results_E4_router.json @@ -0,0 +1,552 @@ +{ + "klass_accuracy": 0.4, + "recall_k": 0.2, + "facts_coverage_mean": 0.533, + "latency_median_ms": 297.8, + "latency_p95_ms": 10745.2, + "by_klass": { + "find_bug_cause": { + "n": 5, + "hits": 1, + "lat": [ + 16452.26749998983, + 271.5598999639042, + 151.70470002340153, + 139.21260001370683, + 136.4670000039041 + ], + "recall": 0.2 + }, + "modify_function": { + "n": 4, + "hits": 0, + "lat": [ + 613.9323999523185, + 323.9513000007719, + 10480.327700031921, + 136.0685999970883 + ], + "recall": 0.0 + }, + "find_impact": { + "n": 3, + "hits": 0, + "lat": [ + 12313.205199956428, + 528.7009999738075, + 7243.187699990813 + ], + "recall": 0.0 + }, + "understand_architecture": { + "n": 4, + "hits": 1, + "lat": [ + 537.1337000397034, + 251.1122000287287, + 7408.832900051493, + 142.22769998013973 + ], + "recall": 0.25 + }, + "find_test": { + "n": 2, + "hits": 0, + "lat": [ + 10745.198499993421, + 720.7019000197761 + ], + "recall": 0.0 + }, + "git_history": { + "n": 4, + "hits": 2, + "lat": [ + 178.19040000904351, + 176.47489998489618, + 159.2209999798797, + 124.12609998136759 + ], + "recall": 0.5 + }, + "find_caller_callee": { + "n": 2, + "hits": 1, + "lat": [ + 385.10400004452094, + 388.4533999953419 + ], + "recall": 0.5 + }, + "prepare_change": { + "n": 4, + "hits": 1, + "lat": [ + 157.5773999793455, + 155.9357000514865, + 158.61889999359846, + 158.9362000231631 + ], + "recall": 0.25 + }, + "verify_change": { + "n": 2, + "hits": 0, + "lat": [ + 9785.04139999859, + 514.9425000417978 + ], + "recall": 0.0 + } + }, + "rows": [ + { + "id": "T1", + "gt_klass": "find_bug_cause", + "pred_klass": "find_caller_callee", + "arm": "quality", + "hit": false, + "latency_ms": 16452.26749998983, + "facts": 4, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\strategy_a_data.json" + ] + }, + { + "id": "T2", + "gt_klass": "modify_function", + "pred_klass": "find_caller_callee", + "arm": "quality", + "hit": false, + "latency_ms": 613.9323999523185, + "facts": 4, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\results_v2.json" + ] + }, + { + "id": "T3", + "gt_klass": "find_impact", + "pred_klass": "find_impact", + "arm": "union", + "hit": false, + "latency_ms": 12313.205199956428, + "facts": 2, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\results_v2.json", + "src\\core\\graph_rag.py", + "experiments\\context_engine\\results_tasks_v3.json" + ] + }, + { + "id": "T4", + "gt_klass": "understand_architecture", + "pred_klass": "understand_architecture", + "arm": "quality", + "hit": true, + "latency_ms": 537.1337000397034, + "facts": 4, + "facts_n": 4, + "files": [ + "src\\core\\search\\graph_adapter.py", + "experiments\\context_engine\\results_v2.json", + "experiments\\context_engine\\tasks_v2.json" + ] + }, + { + "id": "T5", + "gt_klass": "find_test", + "pred_klass": "understand_architecture", + "arm": "quality", + "hit": false, + "latency_ms": 10745.198499993421, + "facts": 3, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\tasks.json", + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\tasks_v2.json" + ] + }, + { + "id": "T6", + "gt_klass": "git_history", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 178.19040000904351, + "facts": 0, + "facts_n": 3, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\archive\\DEV_DIARY_2026_07.md" + ] + }, + { + "id": "T7", + "gt_klass": "find_caller_callee", + "pred_klass": "find_caller_callee", + "arm": "quality", + "hit": true, + "latency_ms": 385.10400004452094, + "facts": 4, + "facts_n": 4, + "files": [ + "src\\core\\indexing\\symbol_index.py", + "scripts\\architecture_linter.py", + "experiments\\context_engine\\results_tasks_v3.json" + ] + }, + { + "id": "T8", + "gt_klass": "prepare_change", + "pred_klass": "git_history", + "arm": "fast", + "hit": false, + "latency_ms": 157.5773999793455, + "facts": 3, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\results_v2.json", + "src\\sources\\__init__.py", + "experiments\\context_engine\\results_tasks_v3.json" + ] + }, + { + "id": "T9", + "gt_klass": "verify_change", + "pred_klass": "modify_function", + "arm": "union", + "hit": false, + "latency_ms": 9785.04139999859, + "facts": 1, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\tasks_v3.json", + "experiments\\rehenie.md", + "experiments\\context_engine\\tasks_v2.json" + ] + }, + { + "id": "T10", + "gt_klass": "find_bug_cause", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 271.5598999639042, + "facts": 1, + "facts_n": 4, + "files": [ + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\FAQ.md" + ] + }, + { + "id": "T11", + "gt_klass": "understand_architecture", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 251.1122000287287, + "facts": 4, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "src\\core\\eta_predictor.py", + "experiments\\context_engine\\results_v2.json" + ] + }, + { + "id": "T12", + "gt_klass": "modify_function", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 323.9513000007719, + "facts": 0, + "facts_n": 4, + "files": [ + "docs\\ru\\ARCHITECTURE_LAYERS.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md" + ] + }, + { + "id": "T13", + "gt_klass": "find_test", + "pred_klass": "find_test", + "arm": "union", + "hit": false, + "latency_ms": 720.7019000197761, + "facts": 2, + "facts_n": 4, + "files": [ + "docs\\en\\CHANGELOG.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\BENCHMARK.md" + ] + }, + { + "id": "T14", + "gt_klass": "git_history", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": true, + "latency_ms": 176.47489998489618, + "facts": 0, + "facts_n": 2, + "files": [ + "scripts\\architecture_linter.py", + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json" + ] + }, + { + "id": "T15", + "gt_klass": "prepare_change", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": true, + "latency_ms": 155.9357000514865, + "facts": 1, + "facts_n": 4, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\LM_STUDIO_SETUP.md" + ] + }, + { + "id": "T16", + "gt_klass": "find_bug_cause", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 151.70470002340153, + "facts": 2, + "facts_n": 4, + "files": [ + "docs\\en\\CHANGELOG.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\CHANGELOG.md" + ] + }, + { + "id": "T17", + "gt_klass": "modify_function", + "pred_klass": "modify_function", + "arm": "union", + "hit": false, + "latency_ms": 10480.327700031921, + "facts": 0, + "facts_n": 4, + "files": [ + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md", + "experiments\\context_engine\\tasks_v3.json", + "src\\core\\indexing\\parser.py" + ] + }, + { + "id": "T18", + "gt_klass": "find_impact", + "pred_klass": "find_impact", + "arm": "union", + "hit": false, + "latency_ms": 528.7009999738075, + "facts": 4, + "facts_n": 4, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_tasks_v3.json", + "KNOWN_ISSUES.md" + ] + }, + { + "id": "T19", + "gt_klass": "understand_architecture", + "pred_klass": "understand_architecture", + "arm": "quality", + "hit": false, + "latency_ms": 7408.832900051493, + "facts": 4, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\tasks_v3.json", + "experiments\\context_engine\\results_tasks_v3.json" + ] + }, + { + "id": "T20", + "gt_klass": "git_history", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": true, + "latency_ms": 159.2209999798797, + "facts": 0, + "facts_n": 3, + "files": [ + "scripts\\architecture_linter.py", + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json" + ] + }, + { + "id": "T21", + "gt_klass": "find_caller_callee", + "pred_klass": "find_caller_callee", + "arm": "quality", + "hit": false, + "latency_ms": 388.4533999953419, + "facts": 4, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json" + ] + }, + { + "id": "T22", + "gt_klass": "modify_function", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 136.0685999970883, + "facts": 2, + "facts_n": 4, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_tasks_v3.json", + "experiments\\context_engine\\tasks_v3.json" + ] + }, + { + "id": "T23", + "gt_klass": "find_bug_cause", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": true, + "latency_ms": 139.21260001370683, + "facts": 4, + "facts_n": 4, + "files": [ + "experiments\\1V_memory_contamination\\memory_contamination_facts_v4_rep_corrected.json", + "experiments\\context_engine\\results_tasks_v3.json", + "src\\core\\intelligence\\layer.py" + ] + }, + { + "id": "T24", + "gt_klass": "understand_architecture", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 142.22769998013973, + "facts": 4, + "facts_n": 4, + "files": [ + "docs\\archive\\AGENTS.md_before_48_and_audit_20260722_215808.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\FAQ.md" + ] + }, + { + "id": "T25", + "gt_klass": "find_bug_cause", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 136.4670000039041, + "facts": 0, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\tasks_v3.json", + "experiments\\research_verification_layers_devto_2026-08-11.md", + "docs\\ru\\FAQ.md" + ] + }, + { + "id": "T26", + "gt_klass": "git_history", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 124.12609998136759, + "facts": 0, + "facts_n": 3, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\FAQ.md" + ] + }, + { + "id": "T27", + "gt_klass": "find_impact", + "pred_klass": "find_caller_callee", + "arm": "quality", + "hit": false, + "latency_ms": 7243.187699990813, + "facts": 1, + "facts_n": 4, + "files": [ + "experiments\\context_engine\\tasks_v2.json", + "experiments\\context_engine\\tasks_v3.json" + ] + }, + { + "id": "T28", + "gt_klass": "prepare_change", + "pred_klass": "git_history", + "arm": "fast", + "hit": false, + "latency_ms": 158.61889999359846, + "facts": 2, + "facts_n": 4, + "files": [ + "docs\\ru\\TELEMETRY.md", + "experiments\\context_engine\\results_tasks_v3.json", + "docs\\ru\\CHANGELOG.md" + ] + }, + { + "id": "T29", + "gt_klass": "verify_change", + "pred_klass": "modify_function", + "arm": "union", + "hit": false, + "latency_ms": 514.9425000417978, + "facts": 0, + "facts_n": 4, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\tasks_v3.json", + "docs\\ru\\SECURITY.md" + ] + }, + { + "id": "T30", + "gt_klass": "prepare_change", + "pred_klass": "find_bug_cause", + "arm": "fast", + "hit": false, + "latency_ms": 158.9362000231631, + "facts": 5, + "facts_n": 5, + "files": [ + "docs\\ru\\FAQ.md", + "experiments\\context_engine\\results_tasks_v3.json", + "KNOWN_ISSUES.md" + ] + } + ] +} \ No newline at end of file diff --git a/experiments/mech_orch/sync_portfolio_lab.py b/experiments/mech_orch/sync_portfolio_lab.py new file mode 100644 index 00000000..86a3e2dc --- /dev/null +++ b/experiments/mech_orch/sync_portfolio_lab.py @@ -0,0 +1,314 @@ +"""One-off lab -> portfolio sync (M1..E4.2, exp-24..exp-31). + +Writes src/data/lab/experiments.json + experiments.ru.json in D:\\Project\\MSPortfolio +preserving their byte conventions: CRLF, indent=2, trailing-newline absent, +ensure_ascii per-file (EN ascii if no literal non-ascii, RU literal utf-8). +Field parity contract: id/date/verdict congruent EN<->RU (guard tests in +MSPortfolio tests/lab.test.ts). +""" +import json +import sys +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +PORTFOLIO = Path(r"D:\Project\MSPortfolio\src\data\lab") + +EN = [ + { + "id": "exp-24", + "date": "2026-08-25", + "project": "mscodebase-intelligence", + "title": "M1: real tool telemetry — 5/62 MCP tools ever used; the working-set boundary is single-digit", + "hypothesis": "Of ~62 registered MCP tools most are dead weight in real sessions; the boundary of the effective working set is a single- or double-digit number.", + "command": "python experiments/mech_orch/tool_metrics_analysis.py (historical tool_metrics.json) + grep error_handler lines of 2MB mscodebase-intelligence.log + debug_runtime_passport/intel_get_runtime_status", + "result": "Registered 32 core + 14 intel + 12 inline + 4 dev = 62; server masks 16/32 by default. tool_metrics.json (entire recorded span): 8 calls across 5 tools — lsp_get_diagnostics 2c/0e, lsp_document_symbols 2c/0e, lsp_get_type_info 2c/2e (15s timeout), lsp_find_definition 1c/0e, lsp_find_references 1c/1e; LSP toolkit 37.5% error rate; search_code has 10 historical timeouts 15-31s.", + "verdict": "partial", + "finding": "Only 5 of 62 tools ever recorded a metric; dead-tool metrics are NOT measurable with current telemetry — a per-call counter in the error_handler path is required. The LSP toolkit (8 calls, 3 errors) is the top latency risk.", + "chart": None, + "conclusion": "How many tools are actually used cannot be measured retroactively — telemetry must log every call (or at least per-tool counters), otherwise the set boundary is intuition, not a metric.", + "links": ["exp-25", "exp-26"], + }, + { + "id": "exp-25", + "date": "2026-08-25", + "project": "mscodebase-intelligence", + "title": "M2: sub-agent in a fresh project — natural 0/5 MCP calls vs MCP-first 9 (4 wasted on unindexed files)", + "hypothesis": "A fresh agent without MCP instructions won't touch MCP tools (goes to grep/read); with instructions it uses them, but on unindexed files semantics is useless.", + "command": "Fixture experiments/mech_orch/lab/ (calc.py/rpn.py/tests, 1 planted bug: reverse-pop in RPN); two sub-agents in parallel: A 'MCP-first, diagnostics only', B 'natural, fix'; baseline 1 failed/4 passed.", + "result": "Agent A (MCP-first): MCP_CALLS=9 IDE_CALLS=5 — passport OK (sub-agents see the same MCP), search_code x2 on lab -> 0 relevant (lab NOT indexed), get_symbol_info('evaluate...') -> not found, read_live_file x3 -> OK, bug found rpn.py:11 (reverse pop). Agent B (natural): MCP_CALLS=0 IDE_CALLS=5 -> 5 passed in 0.04s; baseline after fix 5 passed 0.03s, 1 file changed.", + "verdict": "confirmed", + "finding": "Natural agent: 0/5 MCP; MCP-first: 9 calls, ~4 idle (2 search + get_symbol_info + find_path on unindexed lab), only read_live_file (disk) productive. New files are invisible until reindex -> semantic layer on fresh code = zero. Correctness control: A found the root with exact file:line, B fixed all 4 branches not just '-', tests green.", + "chart": None, + "conclusion": "MCP value lives on INDEXED code; for fresh/foreign dirs meta-tools need a disk-read + ripgrep fallback instead of silent empty answers. The prompt (MCP instruction) is the strongest predictor of tool usage — stronger than tool design.", + "links": ["exp-24", "exp-26"], + }, + { + "id": "exp-26", + "date": "2026-08-25", + "project": "mscodebase-intelligence", + "title": "M3: latency matrix — search fast 75ms HIT vs quality 3666ms MISS; get_symbol_info misses a real symbol", + "hypothesis": "quality semantics gives better context at a comparable cost; get_symbol_info works as an exact tool by symbol name.", + "command": "Live session, same 9003-chunk index: search_code(fast='def get_stale_warning search_tools') -> search_code(quality='how stale warning triggers during reindex blocking') -> get_symbol_info('_get_stale_warning search_tools.py') -> grep -c on disk.", + "result": "search_code(fast) 75ms exact hit search_tools.py:1 (_get_stale_warning); search_code(quality) 3666ms MISS (results_tasks_v3.json + CHANGELOG.md); get_symbol_info('_get_stale_warning search_tools.py') -> 'not found' (real symbol, 2nd miss per session); grep -c 28ms control.", + "verdict": "refuted", + "finding": "quality was 49x slower than fast (3666 vs 75ms) and semantically worse; get_symbol_info misses exact names carrying a trailing hint. On short queries fast search + grep + read_live_file beat quality search + symbol info on time AND accuracy.", + "chart": None, + "conclusion": "Tool smartness does not correlate with usefulness: the cheap exact signal (fast/ripgrep) beats expensive semantics (BGE-M3/BM25 rerank); a QoS threshold for quality (<1.5s) and a fast->grep fallback chain belong inside meta-tools.", + "links": ["exp-24", "exp-27"], + }, + { + "id": "exp-27", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E2: category pilot on the live index — fast 5/6 HIT (83%) vs quality 2/3 + leak to docs/JSON; index self-pollution discovered", + "hypothesis": "(H2.1) the leak between categories (docs/JSON instead of code) is the main quality defect; (H2.2) fast wins on identifiers, quality on prose.", + "command": "6 real queries with ground truth from the session x fast (5) + quality (3), limit=3 (details experiments/mech_orch/E2_category_pilot.md); GT verified earlier by grep/read; after window reload + MCP restart + reindex (9089 chunks).", + "result": "Q1 fast 75ms HIT(top1) vs quality 3666ms MISS->JSON/CHANGELOG; Q2 fast 3779ms HIT cold / 161ms warm; Q3 fast 161ms HIT(domain); Q4 fast 190ms MISS (top1 = own E2 doc: self-pollution) vs quality 6638ms HIT(domain) indexer.py; Q5 fast 172ms HIT vs quality 284ms HIT; Q6 fast 166ms HIT vs quality 2638ms HIT(top1=incident_dataset). fast 5/6=83%; quality 2/3 + 1 domain-hit; warm latency fast 75-190ms, quality 284-6638ms (median ~2.6s).", + "verdict": "partial", + "finding": "fast is 83% and an order of magnitude cheaper; quality saved the only fast-MISS (Q4) at ~35x cost. The leak is confirmed (Q1 quality -> JSON/CHANGELOG; Q4/Q6 quality top-1 incident datasets) but is NOT junk — semantically relevant docs; a category-priority filter is needed, not a prohibition. NEW: the index self-pollutes with its own experiment docs (experiments/mech_orch/* captures code queries). Binary fast/quality routing is suboptimal — cascade fast-hit->stop / MISS->quality+filter+budget.", + "chart": None, + "conclusion": "Query-form->mode is underdetermined without a category filter; adding experiment docs to the index changes ranking (the index is sensitive to its own composition — experiments need ranking control).", + "links": ["exp-26", "exp-28"], + }, + { + "id": "exp-28", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E3: category router on tasks_v3.json (30 tasks) — cascade 0.233 > fast 0.167 > quality 0.133; 4 graph classes 0.00 across all arms", + "hypothesis": "(1) the fast->quality cascade pays off; (2) different klass need different arms (a class router beats a single arm).", + "command": "EXT venv python experiments/mech_orch/E3_category_router_eval.py (limit=8, topk=5; real DB 9089 rows; embedder force llama_cpp — auto-detect in standalone falls into ONNX-fallback, environment artifact). Arms: fast / quality / cascade.", + "result": "fast recall@5 0.167 facts_cov 0.517 lat_med 148ms; quality 0.133 facts_cov 0.861 lat_med 2145ms; cascade 0.233 facts_cov 0.753 lat_med 564ms. BY KLASS (fast/quality/cascade): find_bug_cause 0.20/0.00/0.20 | find_caller_callee 0.00/0.50/0.50 | find_impact 0/0/0 | find_test 0/0/0 | git_history 0.50/0.25/0.50 | modify_function 0/0/0 | prepare_change 0.25/0.00/0.25 | understand_architecture 0.25/0.50/0.50 | verify_change 0/0/0.", + "verdict": "confirmed", + "finding": "Cascade is the winner; the winner depends on klass (git_history/bug/prepare -> fast with quality 0.00 at bug; caller_callee/architecture -> quality); find_test/find_impact/modify/verify_change = 0.00 across ALL arms — search does not replace graph/AST/impact stages.", + "chart": {"type": "bar", "title": "recall@5 by search arm", "data": [{"label": "fast", "value": 0.167}, {"label": "quality", "value": 0.133}, {"label": "cascade", "value": 0.233}]}, + "conclusion": "Search-only gives recall@5 <= 0.23 on real code tasks; klass is a real predictor of the winning arm; a single sweeper is capped on test/impact classes without a graph stage. The 'code passport' must include call graph + impact + test-mapping, not search-only.", + "links": ["exp-27", "exp-29"], + }, + { + "id": "exp-29", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E4: deterministic per-class keyword router (PoC) — 0.200 < cascade 0.233, klass_acc 0.40 (NEGATIVE)", + "hypothesis": "A per-class router (fast/bug+git+prepare, quality/caller+arch, union/test+impact+modify+verify) gives recall >= cascade (0.233) at median < 600ms.", + "command": "EXT venv python experiments/mech_orch/E4_router_poc.py — classifier: 9 keyword rules (order matters), arms per E3; same metrics.", + "result": "router: recall=0.200 facts_cov=0.533 lat_med=298ms p95=10745ms klass_acc=0.40; baselines (E3): fast 0.167 / quality 0.133 / cascade 0.233; BY KLASS: find_bug_cause 0.20 | git_history 0.50 | caller_callee 0.50 | arch 0.25 | prepare 0.25 | modify/test/impact/verify 0.00 ALL (even union fast+quality).", + "verdict": "refuted", + "finding": "klass_acc=0.40 — keyword rules are noisy (bug tasks contain 'callers', git tasks 'why'); the union arm does not save the 4 graph classes — search cannot find what is not textually in the index (callers/callees/impact must come from the graph). The search-only ceiling on this dataset is ~0.23 at ANY routing.", + "chart": {"type": "bar", "title": "recall: keyword router vs cascade", "data": [{"label": "router", "value": 0.2}, {"label": "cascade", "value": 0.233}]}, + "conclusion": "Keyword routing by prompt does not pay off; improvement is query features (symbol tokens, intent verbs, priorities), BUT the ceiling without a graph stage remains. The 'code passport' = search(fast+cascade) + a SEPARATE graph stage (symbol index in memory), otherwise 4/9 classes = 0.00 always.", + "links": ["exp-28", "exp-30"], + }, + { + "id": "exp-30", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E4.1: the graph stage breaks the search-only ceiling — recall 0.433 (cascade 0.267), med 177ms (Track 1)", + "hypothesis": "A graph stage (SymbolIndexAdapter over graph.db, cold-start) lifts the 4 failing classes (find_test/find_impact/modify/verify) above the search-only ceiling; target recall>=0.40 med<600ms.", + "command": "EXT venv python experiments/mech_orch/E4_1_graph_arm.py (same-run: cascade AND cascade+graph in one process; real graph.db 10748 nodes / 33538 edges).", + "result": "same-run: cascade alone 0.267 | +graph arm 0.433 | med=177ms p95=4220ms. BY KLASS (graph vs cascade): find_impact 1.00 vs 0.00 | find_test 0.50 vs 0.00 | modify_function 0.25 vs 0.00 | verify_change 0.00 vs 0.00 | other classes equal. Example graph-arm hits (10-15ms, facts instead of 4-7s quality): T2 modify intel_code_topology -> layer.py | T3 impact _expand_graph_context -> engine.py | T5 test trigger_reindex | T18 impact notify_change -> server_tools.py | T27 impact intel_code_topology.", + "verdict": "confirmed", + "finding": "The graph stage added +0.166 recall on three of the four failing classes at ~15ms latency and lost nowhere (fallback to cascade by construction). Lessons: (1) has_symbol is an exact node-name — search_symbols(LIKE) + strict suffix is required; (2) graph navigation must go through SymbolIndexAdapter(graph.db), not a disk-only SymbolIndex (empty JSON); (3) an empty symbol_index.json did not block the graph — it blocked choosing the wrong instance.", + "chart": {"type": "bar", "title": "recall: cascade vs cascade+graph", "data": [{"label": "cascade", "value": 0.267}, {"label": "cascade+graph", "value": 0.433}]}, + "conclusion": "The graph stage breaks the search-only ceiling. Honest remainder: verify_change stays 0 (T9 'engine' resolves to the wrong node; T29 prompt has no identifiers); facts-coverage for graph rows was not counted (graph returns file paths, not snippets); same-run methodology is mandatory (run-to-run fast/quality variance is high).", + "links": ["exp-28", "exp-29", "exp-31"], + }, + { + "id": "exp-31", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E4.2: deterministic concept resolver (no LLM) — verify_change T9/T29 HIT on real graph.db, facts 4/4 & 3/4 (Track 2)", + "hypothesis": "The two E4.1 remainders — verify_change=0 (T9 wrong-anchor 'engine', T29 no-anchor) and 'graph returns files, not text' (facts=0) — are closed NOT by an LLM classifier but by a mechanical concept-phrase registry (fail-open, klass-gated) BEFORE the lexical extract_symbol (resolution, not classification — RESEARCH.md rec #3). Regression on other classes is structurally excluded: recipes are klass-gated to verify_change, other classes fall back to the old extract_symbol.", + "command": "Layer proof without embedder: python experiments/mech_orch/probe_e42_verify.py (real graph.db, read-only) + python -m pytest tests/test_mech_resolver.py tests/test_symbol_index_persistence.py -q.", + "result": "probe: T9 verify_change sym=notify_change files=[.../src/mcp/server_tools.py] -> HIT gt=src/mcp/server_tools.py facts=4/4 | T29 verify_change sym=_extract_symbol_name files=[..., .../src/core/search/utils.py] -> HIT gt=src/core/search/utils.py facts=3/4 | ALL HIT. Full live same-run (30 tasks, real embedder): cascade 0.267 -> cascade+graph 0.50, med 196.8ms, p95 4514ms; verify_change graph=1.00 (T9 arm=graph:notify_change, T29 arm=graph:_extract_symbol_name) vs cascade 0.00; 9/9 classes >= cascade (no regression). pytest: 14 passed in 2.48s (resolver 10 + persistence 4).", + "verdict": "confirmed", + "finding": "Both verify_change misses resolve to the correct file on the real graph.db (10748 nodes); graph rows now carry facts (graph_fact_text), not 0; regression is excluded by construction (klass-gating). A 'wordless' prompt is an anchor-resolution problem, not classification — lexical extract_symbol survives neither consequence-instead-of-name (T9) nor concept-instead-of-name (T29).", + "chart": None, + "conclusion": "A mechanical concept registry with klass-gating is deterministic and non-regressing; recipe seeding from the 3300-call corpus (RESEARCH.md rec #3). Live same-run confirmed: verify_change 0->1.0, overall recall 0.433->0.50 at med 196.8ms (target >=0.40/<600ms).", + "links": ["exp-30"], + }, +] + +RU = [ + { + "id": "exp-24", + "date": "2026-08-25", + "project": "mscodebase-intelligence", + "title": "M1: реальная телеметрия — 5/62 MCP-инструмента хоть раз вызывались; граница рабочего набора — однозначное число", + "hypothesis": "Из ~62 зарегистрированных MCP-инструментов большинство — мёртвый груз в реальных сессиях; граница эффективного рабочего набора — одно-двузначное число.", + "command": "python experiments/mech_orch/tool_metrics_analysis.py (историч. tool_metrics.json) + grep error_handler-строк 2MB лога mscodebase-intelligence.log + debug_runtime_passport/intel_get_runtime_status", + "result": "Зарегистрировано 32 core + 14 intel + 12 inline + 4 dev = 62; сервер по умолчанию маскирует 16/32. tool_metrics.json (весь записанный период): 8 вызовов на 5 инструментах — lsp_get_diagnostics 2c/0e, lsp_document_symbols 2c/0e, lsp_get_type_info 2c/2e (timeout 15s), lsp_find_definition 1c/0e, lsp_find_references 1c/1e; LSP-тулкит 37.5% ошибок; у search_code 10 исторических таймаутов 15-31s.", + "verdict": "partial", + "finding": "Лишь 5 из 62 инструментов хоть раз записали метрику; мёртвый груз НЕ измеряется текущей телеметрией — нужен счётчик на каждый вызов в error_handler-пути. LSP-тулкит (8 вызовов, 3 ошибки) — главный латентность-риск.", + "chart": None, + "conclusion": "Сколько инструментов реально используется нельзя измерить постфактум — телеметрия обязана писать каждый вызов (или хотя бы per-tool счётчик), иначе граница набора — интуиция, а не метрика.", + "links": ["exp-25", "exp-26"], + }, + { + "id": "exp-25", + "date": "2026-08-25", + "project": "mscodebase-intelligence", + "title": "M2: субагент в свежем проекте — natural 0/5 MCP-вызовов vs MCP-first 9 (4 холостых на неиндексированных файлах)", + "hypothesis": "Свежий агент без инструкции про MCP не тронет MCP-инструменты (уйдёт в grep/read); с инструкцией — использует, но на неиндексированных файлах семантика бесполезна.", + "command": "Фикстура experiments/mech_orch/lab/ (calc.py/rpn.py/tests, 1 подсаженный баг: reverse-pop в RPN); два субагента параллельно: A 'MCP-first, только диагностика', B 'natural, исправить'; baseline 1 failed/4 passed.", + "result": "Agent A (MCP-first): MCP_CALLS=9 IDE_CALLS=5 — passport OK (субагенты видят тот же MCP), search_code x2 по lab -> 0 релевантных (lab НЕ в индексе), get_symbol_info('evaluate...') -> not found, read_live_file x3 -> OK, баг найден rpn.py:11 (reverse pop). Agent B (natural): MCP_CALLS=0 IDE_CALLS=5 -> 5 passed в 0.04s; baseline после фикса 5 passed 0.03s, 1 файл изменён.", + "verdict": "confirmed", + "finding": "Natural-агент: 0/5 MCP; MCP-first: 9 вызовов, ~4 холостых (2 search + get_symbol_info + find_path по неиндексированному lab), продуктивен только read_live_file (диск). Новые файлы невидимы до reindex -> семантический слой на свежем коде = нуль. Контроль правильности: A нашёл корень с точным file:line, B починил все 4 ветки (не только '-'), тесты зелёные.", + "chart": None, + "conclusion": "Ценность MCP-слоя — на ИНДЕКСИРОВАННОМ коде; для свежих/чужих директорий мета-инструментам нужен fallback «read из диска + ripgrep» вместо молчаливого пустого ответа. Промпт (инструкция про MCP) — сильнейший предиктор использования инструментов, сильнее самого дизайна.", + "links": ["exp-24", "exp-26"], + }, + { + "id": "exp-26", + "date": "2026-08-25", + "project": "mscodebase-intelligence", + "title": "M3: матрица латентности — search fast 75ms HIT vs quality 3666ms MISS; get_symbol_info промахивается по реальному символу", + "hypothesis": "«Quality-семантика» даёт лучший контекст за сопоставимое время; get_symbol_info работает как «точный» инструмент по имени символа.", + "command": "Живая сессия, тот же индекс 9003 chunks: search_code(fast='def get_stale_warning search_tools') -> search_code(quality='how stale warning triggers during reindex blocking') -> get_symbol_info('_get_stale_warning search_tools.py') -> grep -c на диске.", + "result": "search_code(fast) 75ms точное попадание search_tools.py:1 (_get_stale_warning); search_code(quality) 3666ms ПРОМАХ (results_tasks_v3.json + CHANGELOG.md); get_symbol_info('_get_stale_warning search_tools.py') -> 'not found' (реальный символ, 2-й miss за сессию); grep -c 28ms контроль.", + "verdict": "refuted", + "finding": "quality в 49× медленнее fast (3666 vs 75ms) и при этом семантически хуже; get_symbol_info промахивается по точным именам с хвостом-подсказкой. На коротких запросах fast search + grep + read_live_file выигрывают у quality search + symbol info по времени И точности.", + "chart": None, + "conclusion": "Степень «умности» инструмента не коррелирует с пользой: дешёвый точный сигнал (fast/ripgrep) бьёт дорогую семантику (BGE-M3/BM25 rerank); QoS-порог для quality (<1.5s) и fallback-цепочка fast->grep обязаны быть в мета-инструментах.", + "links": ["exp-24", "exp-27"], + }, + { + "id": "exp-27", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E2: категорийный пилот на живом индексе — fast 5/6 HIT (83%) vs quality 2/3 + утечка в docs/JSON; обнаружено само-загрязнение индекса", + "hypothesis": "(H2.1) утечка между категориями (docs/JSON вместо кода) — главный дефект quality; (H2.2) fast выигрывает на идентификаторах, quality — на прозе.", + "command": "6 реальных запросов с ground truth из сессии × fast (5) + quality (3), limit=3 (детали experiments/mech_orch/E2_category_pilot.md); GT верифицированы ранее grep/read; после релоада окна + рестарта MCP + реиндекса (9089 chunks).", + "result": "Q1 fast 75ms HIT(top1) vs quality 3666ms MISS->JSON/CHANGELOG; Q2 fast 3779ms HIT холод / 161ms тёплый; Q3 fast 161ms HIT(домен); Q4 fast 190ms MISS (top1 = свой E2-док: само-загрязнение) vs quality 6638ms HIT(домен) indexer.py; Q5 fast 172ms HIT vs quality 284ms HIT; Q6 fast 166ms HIT vs quality 2638ms HIT(top1=incident_dataset). fast 5/6=83%; quality 2/3 + 1 домен-hit; тёплая латентность fast 75-190ms, quality 284-6638ms (медиана ~2.6s).", + "verdict": "partial", + "finding": "fast — 83% и на порядок дешевле; quality спас единственный fast-MISS (Q4) ценой ~35× времени. Утечка подтверждена (Q1 quality -> JSON/CHANGELOG; Q4/Q6 quality top-1 incident-датасеты), но это НЕ мусор — семантически релевантные документы; нужен фильтр по приоритету категорий, не запрет. НОВОЕ: индекс само-загрязняется собственными эксперимент-доками (experiments/mech_orch/* перехватывает кодовые запросы). Бинарный роут fast/quality неоптимален — каскад fast-hit->стоп / MISS->quality+фильтр+бюджет.", + "chart": None, + "conclusion": "«Форма запроса -> режим» недоопределена без категорийного фильтра; добавление эксперимент-доков в индекс меняет ранжирование (индекс чувствителен к своему составу — нужен контроль экспериментов в ранжировании).", + "links": ["exp-26", "exp-28"], + }, + { + "id": "exp-28", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E3: категорийный роутер на tasks_v3.json (30 задач) — каскад 0.233 > fast 0.167 > quality 0.133; 4 граф-класса 0.00 у ВСЕХ рук", + "hypothesis": "(1) каскад fast->quality окупается; (2) разные klass требуют разных рук (роутер по классам > единой руки).", + "command": "EXT venv python experiments/mech_orch/E3_category_router_eval.py (limit=8, topk=5; реальная БД 9089 rows; embedder force llama_cpp — авто-детект в standalone ломается в ONNX-fallback, артефакт среды). Руки: fast / quality / cascade.", + "result": "fast recall@5 0.167 facts_cov 0.517 lat_med 148ms; quality 0.133 facts_cov 0.861 lat_med 2145ms; cascade 0.233 facts_cov 0.753 lat_med 564ms. BY KLASS (fast/quality/cascade): find_bug_cause 0.20/0.00/0.20 | find_caller_callee 0.00/0.50/0.50 | find_impact 0/0/0 | find_test 0/0/0 | git_history 0.50/0.25/0.50 | modify_function 0/0/0 | prepare_change 0.25/0.00/0.25 | understand_architecture 0.25/0.50/0.50 | verify_change 0/0/0.", + "verdict": "confirmed", + "finding": "Каскад — победитель; победитель зависит от klass (git_history/bug/prepare -> fast при quality 0.00 у bug; caller_callee/architecture -> quality); find_test/find_impact/modify/verify_change = 0.00 у ВСЕХ рук — поиск не заменяет граф/AST/impact-стадии.", + "chart": {"type": "bar", "title": "recall@5 по поисковым рукам", "data": [{"label": "fast", "value": 0.167}, {"label": "quality", "value": 0.133}, {"label": "cascade", "value": 0.233}]}, + "conclusion": "Поиск-только даёт recall@5 <= 0.23 на реальных кодовых задачах; klass — реальный предиктор победившей руки; «single sweeper» ограничен на test/impact классах без граф-стадии. «Паспорт кода» обязан включать call graph + impact + test-mapping, а не быть search-only.", + "links": ["exp-27", "exp-29"], + }, + { + "id": "exp-29", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E4: детерминированный keyword-роутер по классам (PoC) — 0.200 < каскад 0.233, klass_acc 0.40 (ОТРИЦАТЕЛЬНЫЙ)", + "hypothesis": "Пер-классный роутер (fast/bug+git+prepare, quality/caller+arch, union/test+impact+modify+verify) даст recall >= каскада (0.233) при медиане < 600ms.", + "command": "EXT venv python experiments/mech_orch/E4_router_poc.py — классификатор: 9 keyword-правил (порядок важен), руки по E3; те же метрики.", + "result": "router: recall=0.200 facts_cov=0.533 lat_med=298ms p95=10745ms klass_acc=0.40; baseline (E3): fast 0.167 / quality 0.133 / cascade 0.233; BY KLASS: find_bug_cause 0.20 | git_history 0.50 | caller_callee 0.50 | arch 0.25 | prepare 0.25 | modify/test/impact/verify 0.00 ВСЕ (даже union fast+quality).", + "verdict": "refuted", + "finding": "klass_acc=0.40 — keyword-правила шумные (bug-задачи содержат «callers», гиты — «почему»); union-рука не спасает 4 граф-класса — поиск не может найти того, чего нет в индексе текстово (callers/callees/impact обязаны прийти из графа). Потолок search-only на этом датасете ~0.23 при ЛЮБОЙ маршрутизации.", + "chart": {"type": "bar", "title": "recall: keyword-роутер vs каскад", "data": [{"label": "router", "value": 0.2}, {"label": "cascade", "value": 0.233}]}, + "conclusion": "Keyword-роутинг по промпту не окупается; улучшение — фичи запроса (symbol-токены, интенты-глаголы, приоритеты), НО потолок без граф-стадии остаётся. «Паспорт кода» = search(fast+cascade) + ОТДЕЛЬНАЯ граф-стадия (symbol index в памяти), иначе 4/9 классов = 0.00 всегда.", + "links": ["exp-28", "exp-30"], + }, + { + "id": "exp-30", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E4.1: граф-стадия пробивает потолок search-only — recall 0.433 (каскад 0.267), med 177ms (Дорожка 1)", + "hypothesis": "Граф-стадия (SymbolIndexAdapter над graph.db, cold-start) поднимает 4 провальных класса (find_test/find_impact/modify/verify) выше потолка search-only; цель recall>=0.40 med<600ms.", + "command": "EXT venv python experiments/mech_orch/E4_1_graph_arm.py (same-run: каскад И каскад+граф в одном процессе; реальный graph.db 10748 узлов / 33538 рёбер).", + "result": "same-run: cascade alone 0.267 | +graph arm 0.433 | med=177ms p95=4220ms. BY KLASS (graph vs cascade): find_impact 1.00 vs 0.00 | find_test 0.50 vs 0.00 | modify_function 0.25 vs 0.00 | verify_change 0.00 vs 0.00 | остальные — equal. Примеры попаданий граф-руки (10-15ms, факты вместо 4-7s quality): T2 modify intel_code_topology -> layer.py | T3 impact _expand_graph_context -> engine.py | T5 test trigger_reindex | T18 impact notify_change -> server_tools.py | T27 impact intel_code_topology.", + "verdict": "confirmed", + "finding": "Граф-стадия добавила +0.166 recall на трёх из четырёх провальных классов при латентности ~15ms и не проиграла нигде (fallback на каскад по построению). Уроки: (1) has_symbol — точное имя узла, нужен search_symbols(LIKE) + strict-суффикс; (2) граф-навигация обязана идти через SymbolIndexAdapter(graph.db), а не обычный SymbolIndex с диска (пустой JSON); (3) пустой symbol_index.json не блокировал граф — блокировал выбор неправильного инстанса.", + "chart": {"type": "bar", "title": "recall: каскад vs каскад+граф", "data": [{"label": "cascade", "value": 0.267}, {"label": "cascade+graph", "value": 0.433}]}, + "conclusion": "Граф-стадия пробивает потолок search-only. Остаток честно: verify_change остаётся 0 (T9 'engine' резолвится не туда; T29 — промпт без идентификаторов); facts-покрытие граф-строк не считалось (граф отдаёт пути файлов, не сниппеты); same-run методология обязательна (run-to-run дисперсия fast/quality высока).", + "links": ["exp-28", "exp-29", "exp-31"], + }, + { + "id": "exp-31", + "date": "2026-08-26", + "project": "mscodebase-intelligence", + "title": "E4.2: детерминированный concept-резолвер (без LLM) — verify_change 0→1.0, recall 0.433→0.50 (Дорожка 2)", + "hypothesis": "Два остатка E4.1 — verify_change=0 (T9 wrong-anchor 'engine', T29 no-anchor) и «граф отдаёт файлы, не текст» (facts=0) — закрываются НЕ LLM-классификатором, а механическим concept-phrase-реестром (fail-open, klass-gated) ПЕРЕД лексическим extract_symbol (резолв, не классификация — RESEARCH.md rec #3). Регресс на других классах структурно исключён: рецепты klass-gated на verify_change, остальные классы fallback на старый extract_symbol.", + "command": "Подтверждение слоя без эмбеддера: python experiments/mech_orch/probe_e42_verify.py (реальный graph.db, read-only) + python -m pytest tests/test_mech_resolver.py tests/test_symbol_index_persistence.py -q; полный live: EXT venv python experiments/mech_orch/E4_1_graph_arm.py", + "result": "probe: T9 verify_change sym=notify_change files=[.../src/mcp/server_tools.py] -> HIT gt=src/mcp/server_tools.py facts=4/4 | T29 verify_change sym=_extract_symbol_name files=[..., .../src/core/search/utils.py] -> HIT gt=src/core/search/utils.py facts=3/4 | ALL HIT. Полный live same-run (30 задач, реальный эмбеддер): cascade 0.267 -> cascade+graph 0.50, med 196.8ms, p95 4514ms; verify_change graph=1.00 (T9 arm=graph:notify_change, T29 arm=graph:_extract_symbol_name) vs cascade 0.00; 9/9 классов >= каскада (без регресса). pytest: 14 passed в 2.48s (resolver 10 + persistence 4).", + "verdict": "confirmed", + "finding": "Оба verify_change-промаха резолвятся в правильный файл на реальном graph.db (10748 узлов); граф-строки теперь несут факты (graph_fact_text), а не 0; регресс исключён по построению (klass-gating) и подтверждён прогоном (9/9 классов >= каскада). «Бессловесный» промпт — задача резолва якоря, а не классификации: лексический extract_symbol не переживает ни «следствие вместо имени» (T9), ни «концепт вместо имени» (T29). Полный live same-run выполнен: recall 0.433→0.50 при med 196.8ms (цель ≥0.40/<600ms).", + "chart": None, + "conclusion": "Механический concept-реестр с klass-gating детерминирован и не регрессит; наполнение рецептов — из 3300-call корпуса (RESEARCH.md rec #3). Live same-run подтвердил: verify_change 0→1.0, recall 0.433→0.50 при med 196.8ms.", + "links": ["exp-30"], + }, +] + +NEGATIVE_RESULTS_EN = [ + { + "attempt": "Per-class deterministic keyword router (fast/bug+git+prepare, quality/caller+arch, union/test+impact+modify+verify) as the orchestration layer replacing a single search arm", + "whyFailed": "klass_acc 0.40 — keyword rules are noisy (bug tasks contain 'callers', git tasks 'why'); the union fast+quality arm did not lift the 4 graph classes (0.00). The search-only ceiling on this dataset is ~0.23 at ANY routing — search cannot find what is not textually in the index.", + "date": "2026-08-26", + "ref": "exp-29", + } +] + +NEGATIVE_RESULTS_RU = [ + { + "attempt": "Пер-классный детерминированный keyword-роутер (fast/bug+git+prepare, quality/caller+arch, union/test+impact+modify+verify) как слой оркестрации вместо единой поисковой руки", + "whyFailed": "klass_acc 0.40 — keyword-правила шумные (bug-задачи содержат «callers», гиты — «почему»); union-рука fast+quality не подняла 4 граф-класса (0.00). Потолок search-only на этом датасете ~0.23 при ЛЮБОЙ маршрутизации — поиск не может найти того, чего нет в индексе текстово.", + "date": "2026-08-26", + "ref": "exp-29", + } +] + + +def _has_literal_non_ascii(path: Path) -> bool: + return any(ord(c) > 127 for c in path.read_text(encoding="utf-8")) + + +def sync() -> None: + en_path = PORTFOLIO / "experiments.json" + ru_path = PORTFOLIO / "experiments.ru.json" + en = json.loads(en_path.read_text(encoding="utf-8")) + ru = json.loads(ru_path.read_text(encoding="utf-8")) + + existing = {e["id"] for e in en["experiments"]} + for e in EN: + if e["id"] in existing: + idx = next(i for i, x in enumerate(en["experiments"]) if x["id"] == e["id"]) + en["experiments"][idx] = e + print(f"[updt] {e['id']}") + continue + en["experiments"].append(e) + print(f"[add ] {e['id']} -> {e['title'][:60]}...") + en["negativeResults"].extend(NEGATIVE_RESULTS_EN) + + existing_ru = {e["id"] for e in ru["experiments"]} + for e in RU: + if e["id"] in existing_ru: + idx = next(i for i, x in enumerate(ru["experiments"]) if x["id"] == e["id"]) + ru["experiments"][idx] = e + continue + ru["experiments"].append(e) + ru["negativeResults"].extend(NEGATIVE_RESULTS_RU) + + # preserve per-file writing conventions: CRLF via default newline translation, + # indent=2, no trailing newline, ensure_ascii matching current literal style. + for path, data in ((en_path, en), (ru_path, ru)): + ensure_ascii = not _has_literal_non_ascii(path) + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=ensure_ascii, indent=2) + + print(f"EN experiments: {len(en['experiments'])} | RU: {len(ru['experiments'])}") + + +if __name__ == "__main__": + try: + sync() + except Exception: + import traceback + + traceback.print_exc() + sys.exit(1) \ No newline at end of file diff --git a/experiments/mech_orch/tool_metrics_analysis.py b/experiments/mech_orch/tool_metrics_analysis.py new file mode 100644 index 00000000..2dc4439b --- /dev/null +++ b/experiments/mech_orch/tool_metrics_analysis.py @@ -0,0 +1,43 @@ +"""Analyze real MCP tool usage from tool_metrics.json (historical telemetry). +Real data: which of the ~60 registered tools are actually used, error rates, latency. +""" +import json +import sys +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +METRICS = Path( + r"C:\Users\misha\AppData\Local\Zed\extensions\mscodebase-intelligence\telemetry\tool_metrics.json" +) + +def main() -> None: + d = json.loads(METRICS.read_text(encoding="utf-8")) + rows = [] + for tool, data in d.items(): + calls = int(data.get("calls", 0) or 0) + errs = int(data.get("errors", 0) or 0) + tms = int(data.get("total_ms", 0) or 0) + route = str(data.get("route", "")) + last = str(data.get("last_call", "")) + rows.append((tool, calls, errs, tms, route, last)) + rows.sort(key=lambda r: -r[1]) + total = sum(r[1] for r in rows) + print(f"{'TOOL':30}{'CALLS':>8}{'ERR':>6}{'AVG_MS':>9} ROUTE") + print("-" * 100) + for tool, calls, errs, tms, route, last in rows: + avg = tms / calls if calls else 0.0 + print(f"{tool:30}{calls:8}{errs:6}{avg:9.0f} {route[:60]}") + print("-" * 100) + print(f"TOTAL_CALLS={total} TOOLS_WITH_DATA={len(rows)}") + zero = [t for t, c, *_ in rows if c == 0] + print(f"TOOLS_WITH_ZERO_CALLS={len(zero)}: {zero}") + +if __name__ == "__main__": + try: + main() + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) \ No newline at end of file diff --git a/experiments/misc_probes/EXP1_lock_orphan.md b/experiments/misc_probes/EXP1_lock_orphan.md new file mode 100644 index 00000000..165143fb --- /dev/null +++ b/experiments/misc_probes/EXP1_lock_orphan.md @@ -0,0 +1,35 @@ +# EXPERIMENT 1 — [2026-08-26] Гипотеза: DatabaseLock классифицирует ЖИВОЙ MCP как ORPHAN + +**Ожидание:** parent_chain для честного holder'а (venvwlauncher-цепочка) содержит +мёртвого предка ВЫШЕ живого Zed → classify_holder вернёт ORPHAN (ложный позитив). + +**Команда:** +```bash +venv/Scripts/python.exe experiments/misc_probes/probe_lock_classify.py 11832 8260 21924 22112 +``` + +**Сырой результат:** +``` +PID alive decision parent-chain +11832 True orphan 11832:pythonw.exe[alive] <- 22112:pythonw.exe[alive] <- 21296:?[dead] + ^^^ WOULD BE TERMINATED by DatabaseLock (TerminateProcess) +8260 True orphan 8260:pythonw.exe[alive] <- 21924:pythonw.exe[alive] <- 23988:?[dead] + ^^^ WOULD BE TERMINATED by DatabaseLock (TerminateProcess) +21924 True orphan 21924:pythonw.exe[alive] <- 23988:?[dead] + ^^^ WOULD BE TERMINATED by DatabaseLock (TerminateProcess) +22112 True orphan 22112:pythonw.exe[alive] <- 21296:?[dead] + ^^^ WOULD BE TERMINATED by DatabaseLock (TerminateProcess) +``` + +**Вердикт:** ПОДТВЕРЖДЕНА. Все 4 живых MCP-процесса → ORPHAN. +Корень: `parent_chain()` останавливается на первом мёртвом предке +(завершившаяся venv-обёртка/прослойка), не доходя до живого Zed.exe выше. +Ветка `chain[-1] dead & no Zed → ORPHAN → TerminateProcess` убивает живой MCP. + +**Урок:** эвристика «живой Zed в цепочке» ненадёжна, когда между holder'ом и Zed +есть завершившиеся промежуточные процессы (venvwlauncher → dead). Требуются: +(а) проверка command line holder'а (наш venv + src.main → HEALTHY), или +(б) никогда не убивать живые процессы из «нашего» окружения, или +(в) kill только по явному критерию (lock возраст + PID мёртв). + +Связь: EXPERIMENTS_LOG — этот жечас. Воспроизводит инцидент 2026-08-26 (PID 20052 killed by 12524). \ No newline at end of file diff --git a/experiments/misc_probes/REDTEAM_lock_attacks.md b/experiments/misc_probes/REDTEAM_lock_attacks.md new file mode 100644 index 00000000..194f8e41 --- /dev/null +++ b/experiments/misc_probes/REDTEAM_lock_attacks.md @@ -0,0 +1,61 @@ +# РФД ТИМ AI — DatabaseLock: атаки со всех сторон (2026-08-26) + +## Атакованный объект +`src/core/indexing/database_lock.py` (classify_holder → ORPHAN → TerminateProcess любому +живому процессу) + `src/core/indexing/db_manager.py` (жизненный цикл lock'а). + +## Методика +- Эмпирика на живых процессах (без Terminate для системных — только classify). +- Unit-анализ кода (db_manager, terminate path, TOCTOU). +- Эксперименты: E1 (4/4 живых MCP → ORPHAN), E2 (holder с мёртвым родителем убит), + probe3 (поддельный lock → ORPHAN для explorer.exe). + +## Результаты атак + +### АТАКА 1 [P0] — поддельный lock → произвольный kill системного процесса ✅ ПОДТВЕРЖДЕНО +Любой процесс того же user'а пишет lock-файл с `pid=24744 (explorer.exe)` + +`started=старое` → classify_holder → **ORPHAN** → при `acquire()` сработает +`TerminateProcess(24744)`. Убийство живого системного процесса по поддельному файлу. +Репро: probe3 → `classify_holder -> orphan`. + +### АТАКА 2 [P0] — ложный ORPHAN для живого MCP (настоящий инцидент) ✅ ПОДТВЕРЖДЕНО +Живой MCP (venvwlauncher-цепочка: pythonw → pythonw → мёртвый предок) → walk +`parent_chain` обрывается на первом мёртвом звене (L202 `if not alive: break`), +не доходит до живого Zed выше → `chain[-1] dead` + нет `"Zed"` → ORPHAN → терминация. +Репро: E1 — все 4 живых MCP → orphan; E2 — holder pid=1328 убит, lock украден. + +### АТАКА 3 [P1] — TOCTOU: PID-reuse между classify и terminate ⚠️ КОД-АНАЛИЗ +`_terminate_holder` (L440-470) не перепроверяет create_time перед TerminateProcess. +Окно: classify(ORPHAN) → holder умирает → PID переиспользован новой программой → +TerminateProcess убивает НЕВИНОВНОГО. Реальных условий kill'а «по наследству». + +### АТАКА 4 [P1] — lock держится ВСЮ жизнь менеджера ⚠️ КОД-АНАЛИЗ +`LanceDBManager.__init__` (db_manager.py:83-92) вызывает `_db_lock.acquire()` и +raise при LockBusyError → второе окно Zed на ту же БД **не стартует вообще** +(«Закройте второе окно»), даже для read-only. атака availability multi-window. + +### АТАКА 5 [P1] — reacquire после rmtree без fail → два писателя ⚠️ КОД-АНАЛИЗ +`recreate_table_physical` (db_manager.py:490-493): release → rmtree → acquire; +при LockBusyError — только `logger.warning` и ПРОДОЛЖЕНИЕ без lock → два процесса +пишут в БД одновременно (нарушение single-writer). + +### АТАКА 6 [P2] — нет hostname в lock ⚠️ КОД-АНАЛИЗ +`_write_owner` пишет только pid/started/role. На сетевом share PID другой машины +непроверяем → чужой host'овый PID выглядит «мёртвым» → steal → два писателя с +двух машин. filelock/QLockFile хранят hostname. + +### АТАКА 7 [P3] — нет версии формата lock ⚠️ КОД-АНАЛИЗ +Формат {pid, started, role} без version — миграции невозможны. + +## Промежуточный вердикт +Любые варианты «умного kill'а» (A-вариант с эвристиками) не закрывают АТАКИ 1 и 3. +Единственный полный фикс — **радикальный fail-closed: живой PID → HELD (ждать → +LockBusyError), TerminateProcess удалить из acquire полностью** (закрывает 1,2,3), +плюс: hostname в lock (6), version (7), дефолтный lock только на время записи (4), +reacquire-fail → raise (5). + +## Вывод +Текущий дизайн «self-healing kill» небезопасен в принципе: он позволяет убивать +произвольные живые процессы (включая системные) через поддельный lock. Индустрия +(PostgreSQL/Qt/filelock) — «break stale lock only on proof of death», т.е. только +мёртвый PID / PID-reuse-токен, никаких TerminateProcess живых. \ No newline at end of file diff --git a/experiments/misc_probes/exp2_lock_attacker.py b/experiments/misc_probes/exp2_lock_attacker.py new file mode 100644 index 00000000..8bfae9d1 --- /dev/null +++ b/experiments/misc_probes/exp2_lock_attacker.py @@ -0,0 +1,106 @@ +"""EXPERIMENT 2 (attacker): живой holder с мёртвым родителем убивается текущим кодом. + +Сценарий 1-в-1 с инцидентом 2026-08-26: +1. launcher-процесс (аналог venvwlauncher) спавнит holder (аналог живого MCP другого окна) + с DETACHED_PROCESS и ЗАВЕРШАЕТСЯ → у holder'а родитель мёртв. +2. attacker (мы, аналог второго окна) вызывает DatabaseLock.acquire() на тот же lock. + Текущий код: classify_holder → parent_chain=[holder alive, launcher dead] → ORPHAN + → TerminateProcess(holder) → lock stolen. + Ожидаемое правильное поведение: LockBusyError, holder жив (fail-closed). + +Usage: + venv/Scripts/python.exe experiments/misc_probes/exp2_lock_attacker.py +""" + +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from src.core.indexing.database_lock import ( # noqa: E402 + DatabaseLock, + LockBusyError, + WindowsProcessInspector, +) + +HOLDER = Path(__file__).resolve().parent / "exp2_lock_holder.py" +LAUNCHER = ( + "import subprocess,sys;" + "subprocess.Popen([sys.executable, r'%s', r'%s', '%s']," + " creationflags=subprocess.DETACHED_PROCESS|subprocess.CREATE_NO_WINDOW)" +) + + +def main() -> int: + lock_path = Path(sys.argv[1]) + hold_sec = float(sys.argv[2]) + lock_path.unlink(missing_ok=True) + + # 1. Launcher спавнит holder и выходит (родитель holder'а = launcher, мёртв). + launcher_code = LAUNCHER % (HOLDER, lock_path, hold_sec) + subprocess.run( + [sys.executable, "-c", launcher_code], + timeout=10, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + time.sleep(1.2) # holder успел взять lock + print(f"[attacker] lock exists={lock_path.exists()}", flush=True) + + # 2. Находим holder'а: процесс, держащий lock (по содержимому файла). + holder_pid = None + for _ in range(20): + try: + import json + + data = json.loads(lock_path.read_text(encoding="utf-8")) + holder_pid = data.get("pid") + if holder_pid: + break + except Exception: # noqa: BLE001 + pass + time.sleep(0.2) + print(f"[attacker] holder pid={holder_pid}", flush=True) + if not holder_pid: + print("VERDICT: holder not found — abort") + return 2 + + insp = WindowsProcessInspector() + chain = insp.parent_chain(holder_pid, max_levels=8) + print(f"[attacker] holder alive={insp.is_alive(holder_pid)} parent-chain={chain}", flush=True) + + # 3. Атакуем (второй процесс пытается захватить тот же lock). + lock = DatabaseLock(lock_path, wait_timeout=2.0, poll_interval=0.2) + try: + lock.acquire() + outcome = "ACQUIRED (stolen!)" + except LockBusyError as exc: + outcome = f"LockBusyError (fail-closed): {exc}" + except RuntimeError as exc: + outcome = f"RuntimeError: {exc}" + + holder_alive = insp.is_alive(holder_pid) if holder_pid else False + print(f"[attacker] outcome: {outcome}", flush=True) + print(f"[attacker] holder_alive_after_attempt={holder_alive}", flush=True) + + # cleanup: убиваем holder если жив (это НЕ реальный MCP, а тестовый процесс) + if holder_alive: + try: + holder = __import__("ctypes").windll.kernel32 + handle = holder.OpenProcess(0x0001, False, holder_pid) + if handle: + holder.TerminateProcess(handle, 1) + holder.CloseHandle(handle) + except Exception: # noqa: BLE001 + pass + lock_path.unlink(missing_ok=True) + if holder_alive: + print("VERDICT: holder survived — FAIL-CLOSED OK", flush=True) + return 0 + print("VERDICT: holder killed — BUG REPRODUCED", flush=True) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/experiments/misc_probes/exp2_lock_holder.py b/experiments/misc_probes/exp2_lock_holder.py new file mode 100644 index 00000000..58a7f055 --- /dev/null +++ b/experiments/misc_probes/exp2_lock_holder.py @@ -0,0 +1,40 @@ +"""EXPERIMENT 2 — живой holder с мёртвым родителем: текущий DatabaseLock УБИВАЕТ его. + +Воспроизводит сценарий инцидента 2026-08-26 (PID 20052 killed by 12524): +1. holder.py (этот файл, вызванный как дочерний процесс) захватывает lock и живёт N сек. +2. Родитель выходит → у holder'а parent chain: [[holder, alive], [ppid, DEAD/missing]] + (ровно как venvwlauncher-цепочка реального MCP). +3. attacker.py вызывает DatabaseLock.acquire() на тот же lock. + Текущий код: classify_holder → ORPHAN → TerminateProcess(holder) → stolen. + Ожидаемое правильное поведение: LockBusyError, holder жив. + +Запуск: venv/Scripts/python.exe attacker.py + +Usage (from holder): + venv/Scripts/python.exe experiments/misc_probes/exp2_lock_holder.py +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from src.core.indexing.database_lock import DatabaseLock # noqa: E402 + + +def main() -> int: + lock_path = Path(sys.argv[1]) + hold_sec = float(sys.argv[2]) + lock = DatabaseLock(lock_path) + lock.acquire() + print(f"[holder pid={os.getpid()}] lock acquired, holding {hold_sec}s", flush=True) + time.sleep(hold_sec) + lock.release() + print(f"[holder pid={os.getpid()}] released", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/experiments/misc_probes/probe3_fake_lock_foreign_pid.py b/experiments/misc_probes/probe3_fake_lock_foreign_pid.py new file mode 100644 index 00000000..182499b5 --- /dev/null +++ b/experiments/misc_probes/probe3_fake_lock_foreign_pid.py @@ -0,0 +1,48 @@ +"""PROBE 3 — атака: поддельный lock с чужим живым PID (explorer.exe). + +Атакующий (тот же user, другой процесс) пишет lock-файл с pid системного +процесса explorer.exe + started=прошлое. Что вернёт КЛАССИФИКАЦИЯ (без kill)? +Если ORPHAN — DatabaseLock ТЕРМИНИРУЕТ explorer.exe (kill произвольного +процесса по поддельному lock). + +Безопасно: только classify_holder(), TerminateProcess не вызывается. +""" + +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from src.core.indexing.database_lock import ( # noqa: E402 + DatabaseLock, + LockHolderState, +) + + +def main() -> int: + target_pid = int(sys.argv[1]) if len(sys.argv) > 1 else None + if not target_pid: + print("usage: probe3_fake_lock_foreign_pid.py [lock_path]") + return 2 + + lock_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(sys.argv[2] if len(sys.argv) > 2 else f"{Path.home()}/fake_lock_test.lock") + lock_path.unlink(missing_ok=True) + + # Атакующий пишет lock на чужой живой PID со «старым» started. + fake = {"pid": target_pid, "started": time.time() - 3600, "role": "worker"} + lock_path.write_text(json.dumps(fake), encoding="utf-8") + + lock = DatabaseLock(lock_path, wait_timeout=0.5, poll_interval=0.1) + state = lock.classify_holder(target_pid, fake["started"], holder_data=fake) + print(f"[probe3] pid={target_pid}: classify_holder -> {state.value}") + if state.value == "healthy": + print("[probe3] FIXED: живой процесс HELD (wait), TerminateProcess удалён") + elif state.value == "ambiguous": + print("[probe3] safe: непроверяемый holder → fail-closed wait") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/experiments/misc_probes/probe_lock_classify.py b/experiments/misc_probes/probe_lock_classify.py new file mode 100644 index 00000000..b619c424 --- /dev/null +++ b/experiments/misc_probes/probe_lock_classify.py @@ -0,0 +1,63 @@ +"""PROBE: classify real live MCP processes with the real WindowsProcessInspector. + +Reproduces the false-positive ORPHAN decision of DatabaseLock.classify_holder +against the CURRENTLY RUNNING MCP servers (multi-window), the same way the +killer instance (PID 12524) classified the victim (PID 20052) at 19:42:52. + +Usage: + venv/Scripts/python.exe experiments/misc_probes/probe_lock_classify.py [pid...] +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from src.core.indexing.database_lock import ( # noqa: E402 + LockHolderState, + WindowsProcessInspector, +) + + +def dump_chain(chain): + if not chain: + return None + return " <-".join( + f" {pid}:{name}[{'alive' if alive else 'dead'}]" for pid, name, alive in chain + ) + + +def main(pids): + insp = WindowsProcessInspector() + print(f"{'PID':>8} {'alive':>5} {'decision':<10} parent-chain") + print("-" * 100) + for pid in pids: + alive = insp.is_alive(pid) + chain = insp.parent_chain(pid, max_levels=8) + ct = insp.create_time(pid) + # Честный holder: lock-файл записан ПОСЛЕ создания процесса. + started = (ct + 5.0) if ct else (time.time() + 5.0) + # Реплика решения classify_holder (строки 413-438 database_lock.py): + if not alive: + decision = LockHolderState.DEAD + elif pid == os.getpid(): + decision = LockHolderState.HEALTHY + elif ct is not None and ct > started + 2.0: + decision = LockHolderState.DEAD + else: + # R3TF: живой PID с валидным create_time → HELD (wait), never kill + decision = LockHolderState.HEALTHY + print(f"{pid:>8} {str(alive):>5} {decision.value:<10} {dump_chain(chain)}") + if decision.value != "healthy": + print(f" note: {decision.value} — но TerminateProcess удалён (R3TF)") + return None + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(2) + pids = [int(p) for p in sys.argv[1:]] + main(pids) \ No newline at end of file diff --git a/experiments/test_symbol_index_persistence.py b/experiments/test_symbol_index_persistence.py new file mode 100644 index 00000000..6844f25c --- /dev/null +++ b/experiments/test_symbol_index_persistence.py @@ -0,0 +1,122 @@ +""" +E4: SymbolIndex Persistence Bug — воспроизведение и замер. + +Гипотеза: SymbolIndexAdapter в MODE_PURE хранит данные в PropertyGraph, +но save_symbol_index читает _definitions → пустой JSON. + +Замер: сколько символов в памяти vs на диске при cold-start. +""" +import sys +import json +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import tempfile +from src.core.indexing.symbol_index import SymbolIndex +from src.core.search.graph_adapter import SymbolIndexAdapter +from src.core.graph import PropertyGraph + + +def _make_pg(): + tmpdir = tempfile.mkdtemp() + return PropertyGraph(db_path=Path(tmpdir) / "graph.db") + + +def experiment_1_pure_vs_hybrid(): + """Сравнение PURE vs HYBRID: что попадает в _definitions.""" + pg = _make_pg() + + # HYBRID + hybrid = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_HYBRID) + hybrid.add_definitions("test.py", [ + {"name": "foo", "line": 10, "kind": "function"}, + {"name": "Bar", "line": 20, "kind": "class"}, + ]) + hybrid.add_definitions("test2.py", [ + {"name": "baz", "line": 5, "kind": "function"}, + ]) + + # PURE + pure = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) + pure.add_definitions("test.py", [ + {"name": "foo", "line": 10, "kind": "function"}, + {"name": "Bar", "line": 20, "kind": "class"}, + ]) + pure.add_definitions("test2.py", [ + {"name": "baz", "line": 5, "kind": "function"}, + ]) + + print("=== Experiment 1: PURE vs HYBRID ===") + print(f"HYBRID _definitions count: {len(hybrid._definitions)}") # Ожидаем 3 + print(f"PURE _definitions count: {len(pure._definitions)}") # Ожидаем 0 + print(f"PURE graph nodes: {pure._graph.count_nodes()}") # Ожидаем > 0 + print() + + +def experiment_2_save_roundtrip(): + """Симуляция save→load цикла: PURE adapter → пустой JSON.""" + pg = _make_pg() + adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) + adapter.add_definitions("src/main.py", [ + {"name": "process_data", "line": 42, "kind": "function"}, + {"name": "DataProcessor", "line": 100, "kind": "class"}, + ]) + adapter.add_references("src/main.py", [ + {"caller": "process_data", "callee": "validate_input", "line": 45}, + ]) + + print("=== Experiment 2: Save Roundtrip ===") + print(f"In-memory _definitions: {len(adapter._definitions)} keys") + print(f"In-memory _references: {len(adapter._references)} keys") + print(f"Graph nodes: {adapter._graph.count_nodes()}") + + # Симуляция save_symbol_index (из index_guard.py:366-398) + defs = {k: [r.to_dict() for r in v] for k, v in adapter._definitions.items()} + refs = {k: [r.to_dict() for r in v] for k, v in adapter._references.items()} + fts = {k: list(v) for k, v in adapter._file_to_symbols.items()} + + data = {"definitions": defs, "references": refs, "file_to_symbols": fts} + + print(f"Serialized definitions: {len(defs)} keys") + print(f"Serialized references: {len(refs)} keys") + print(f"JSON output: {json.dumps(data, indent=2)}") + print() + + # Вердикт + if len(defs) == 0 and len(adapter._graph._nodes) > 0: + print("BUG CONFIRMED: PureGraph data in graph, but _definitions empty → JSON is empty") + else: + print("BUG NOT REPRODUCED (unexpected state)") + + +def experiment_3_cold_start_sim(): + """Симуляция cold-start: загрузка пустого JSON → 0 символов.""" + print("=== Experiment 3: Cold-Start Simulation ===") + + # Создаём пустой JSON (какой бы записал save_symbol_index для PURE) + empty_json = json.dumps({ + "definitions": {}, + "references": {}, + "file_to_symbols": {}, + "saved_at": "2026-08-27T00:00:00" + }) + + # Симуляция load_symbol_index + raw = json.loads(empty_json) + loaded_defs = raw.get("definitions", {}) + loaded_refs = raw.get("references", {}) + + print(f"Loaded definitions: {len(loaded_defs)} keys") + print(f"Loaded references: {len(loaded_refs)} keys") + + if len(loaded_defs) == 0: + print("COLD-START: SymbolIndex пуст → Recall = 0.00 (подтверждено)") + print() + + +if __name__ == "__main__": + experiment_1_pure_vs_hybrid() + experiment_2_save_roundtrip() + experiment_3_cold_start_sim() diff --git a/scripts/negative_controls/manifest.json b/scripts/negative_controls/manifest.json index 44f1c614..5aa89639 100644 --- a/scripts/negative_controls/manifest.json +++ b/scripts/negative_controls/manifest.json @@ -36,7 +36,7 @@ "output_contains": [ "STALE NEGATIVE CONTROL: PASSED" ], - "fixture_digest": "6a2c174fc5fab01cbf9cb00bbcc337090d56ee5fbdc06c6fc4108c98613f715e" + "fixture_digest": "02a8f1d61a14656f044c4e4a796453601a5974d34b7810a37d254a6b7ed9de73" }, { "id": "dead_guard_classifier", @@ -57,5 +57,5 @@ "fixture_digest": "0913eb0455dad68770669b2aadb3e0759b3539f3ba5b35e0c79ce3bf755484d1" } ], - "min_accepted_revision": "6ba8f6c09243a13d52a31c8780769fe8ae3643f7" + "min_accepted_revision": "682b5af0435f84ebc682201c3a4aa7462d84c46c" } diff --git a/scripts/negative_controls/pin_log.json b/scripts/negative_controls/pin_log.json index 331f095a..e448045e 100644 --- a/scripts/negative_controls/pin_log.json +++ b/scripts/negative_controls/pin_log.json @@ -88,5 +88,14 @@ "stale_detector": "6a2c174fc5fab01cbf9cb00bbcc337090d56ee5fbdc06c6fc4108c98613f715e", "dead_guard_classifier": "0913eb0455dad68770669b2aadb3e0759b3539f3ba5b35e0c79ce3bf755484d1" } + }, + { + "pinned_at_utc": "2026-08-26T16:41:19Z", + "reason": "stale_config.json exclude_files + ARCLUX_AUDIT_2026-08-26.md (external 3rd-party audit artifact); digest invalidated by config edit, control logic unchanged", + "digests": { + "drift_gate": "8fe3226bb877415ab76225ac6136fbb41d75c613b82c8f24e8fddfda297b5c3f", + "stale_detector": "02a8f1d61a14656f044c4e4a796453601a5974d34b7810a37d254a6b7ed9de73", + "dead_guard_classifier": "0913eb0455dad68770669b2aadb3e0759b3539f3ba5b35e0c79ce3bf755484d1" + } } ] diff --git a/src/core/indexing/database_lock.py b/src/core/indexing/database_lock.py index c9a28446..15988e7b 100644 --- a/src/core/indexing/database_lock.py +++ b/src/core/indexing/database_lock.py @@ -13,15 +13,26 @@ - release() идемпотентен и удаляет файл ТОЛЬКО если lock наш (_acquired) — в отличие от оригинала, не может снять чужой lock на Unix. -Self-healing (WS9, KNOWN_ISSUES#2026-08-08-multiwindow-pidlock): +Self-healing (WS9, KNOWN_ISSUES#2026-08-08-multiwindow-pidlock) — +первая версия использовала parent-chain + TerminateProcess «зомби»: +цепочка venvwlauncher-процесса обрывается на мёртвом предке ДО живого Zed +→ живой MCP другого окна убивался (инцидент 2026-08-26, PID 20052 +killed by 12524; REDTEAM_lock_attacks.md, атаки 1-4). + +R3TF (2026-08-26) — «break stale lock only on proof of death» +(PostgreSQL postmaster.pid / Qt QLockFile / python filelock): - PID validation: мёртвый PID → steal (как раньше); -- create_time guard: процесс с данным PID, созданный ПОСЛЕ записи lock - (create_time > started + tolerance) → lock не от этого процесса (PID-reuse) → steal; -- parent-chain orphan detection (Windows, walk ≤ 8): живой Zed.exe в цепочке - → HEALTHY (ждём до wait_timeout, soft LockBusyError); корень цепочки мёртв - → ORPHAN (TerminateProcess зомби → дождаться смерти → повторно проверить - lock → steal); иначе / не-Windows → AMBIGUOUS (fail-closed: никого не убиваем, - только wait → LockBusyError). +- create_time guard: процесс создан ПОЗЖЕ записи lock (create_time > + started + tolerance) → PID-reuse/подделка → steal; +- hostname в lock (v2): lock с другой машины → AMBIGUOUS (PID чужого + хоста непроверяем локально) → ждём, не трогаем; +- любой ЖИВОЙ PID с совпадающим create_time → HEALTHY (wait → soft + LockBusyError), НИКОГДА не терминейтится. TerminateProcess удалён. +- parent_chain остаётся только для диагностики (server_factory, + lsp_project_bridge), из решения исключён. + +Инцидент-фикс: `classify_holder` больше НЕ возвращает ORPHAN, `acquire()` +не вызывает `_terminate_holder`. Fail-closed: непроверяемый holder → ждём. Инжектируемый ProcessInspector позволяет тестам симулировать цепочки процессов без реальных OS-процессов (Linux CI). @@ -35,6 +46,7 @@ import json import logging import os +import socket import sys import time from enum import Enum @@ -45,6 +57,10 @@ LOCK_ROLE = "worker" +# Версия формата lock-файла (hostname добавлен в v2, R3TF-harden 2026-08-26). +# Несовпадающая/отсутствующая версия → AMBIGUOUS (fail-closed, не трогаем). +LOCK_FORMAT_VERSION = 2 + # WS9: дефолтное ожидание сокращено 30s → 8s (Zed request timeout = 60s/запрос, # но 8s достаточно для легитимного контеншена; мягкий LockBusyError вместо # жёсткого RuntimeError-краха при живом holder'е). @@ -52,19 +68,24 @@ # create_time guard: процесс создан позже записи lock более чем на это # значение → lock не от него (PID-reuse / подделка). CREATE_TIME_TOLERANCE = 2.0 -# Сколько ждать фактической смерти зомби после TerminateProcess. -TERMINATE_TIMEOUT = 5.0 -# Максимальная глубина walk по цепочке родителей. +# Сколько ждать освобождения lock-файла (unlink) при PermissionError +# (Windows держит fd мгновение после смерти holder'а). +UNLINK_RETRY_TIMEOUT = 5.0 +# Максимальная глубина walk по цепочке родителей (диагностика, НЕ для решения). PARENT_CHAIN_MAX_LEVELS = 8 class LockHolderState(Enum): - """Классификация holder'а lock-файла (см. classify_holder).""" + """Классификация holder'а lock-файла (см. classify_holder). + + ORPHAN удалён (R3TF 2026-08-26): живой PID больше НЕ может быть + классифицирован как «зомби» — TerminateProcess по эвристике убивал + живые MCP. Индустрия: «break stale lock only on proof of death». + """ DEAD = "dead" # PID мёртв или PID-reuse → steal - HEALTHY = "healthy" # живой процесс окна Zed (или наш собственный) → wait - ORPHAN = "orphan" # живой, но осиротевший (корень цепочки мёртв) → terminate+steal - AMBIGUOUS = "ambiguous" # не можем определить → fail-closed (wait, не убивать) + HEALTHY = "healthy" # живой процесс (наш или другого окна) → wait + AMBIGUOUS = "ambiguous" # не можем проверить (чужой host/format) → fail-closed wait class LockBusyError(RuntimeError): @@ -222,7 +243,8 @@ class DatabaseLock: poll_interval: шаг опроса в цикле ожидания. holder_inspector: ProcessInspector для классификации holder'а (по умолчанию — платформенный). - terminate_timeout: сколько ждать фактической смерти зомби. + unlink_retry_timeout: сколько ждать освобождения lock-файла (unlink) + при PermissionError. create_time_tolerance: допуск create_time guard (PID-reuse). """ @@ -234,14 +256,14 @@ def __init__( poll_interval: float = 0.25, *, holder_inspector: Optional[ProcessInspector] = None, - terminate_timeout: float = TERMINATE_TIMEOUT, + unlink_retry_timeout: float = UNLINK_RETRY_TIMEOUT, create_time_tolerance: float = CREATE_TIME_TOLERANCE, ) -> None: self.lock_path = Path(lock_path) self.wait_timeout = wait_timeout self.retry_attempts = retry_attempts self._poll_interval = poll_interval - self._terminate_timeout = terminate_timeout + self._unlink_retry_timeout = unlink_retry_timeout self._create_time_tolerance = create_time_tolerance self._inspector = holder_inspector or self._make_default_inspector() self._fd: Optional[int] = None @@ -262,9 +284,9 @@ def acquire(self) -> None: Raises: LockBusyError (RuntimeError): lock занят живым/неопределимым - процессом дольше wait_timeout, или зомби не удалось - завершить, или гонка при retry. Holder НЕ убивается, - кроме подтверждённого ORPHAN (зомби, корень цепочки мёртв). + процессом дольше wait_timeout, или гонка при retry. + Holder НЕ убивается никогда (R3TF: только proof-of-death + steal: мёртвый PID / подтверждённый PID-reuse). """ if self._acquired: return @@ -290,33 +312,22 @@ def acquire(self) -> None: holder_started = (holder.get("started") or 0) if holder is not None else 0 if holder is not None and holder_pid is not None: - state = self.classify_holder(holder_pid, holder_started) - if state is LockHolderState.HEALTHY or state is LockHolderState.AMBIGUOUS: - # Живой процесс окна Zed (или неопределимый) — ждём до - # wait_timeout; holder НЕ трогаем (fail-closed). + state = self.classify_holder(holder_pid, holder_started, holder_data=holder) + if state is LockHolderState.DEAD: + # steal обрабатывается кодом ниже (после TOCTOU-проверки). + pass + else: + # HEALTHY или AMBIGUOUS — живой/непроверяемый holder: + # ждём до wait_timeout, holder НЕ трогаем (fail-closed). logger.warning( f"PID lock held by {state.value} pid={holder_pid}, " f"waiting up to {self.wait_timeout}s..." ) self._wait_for_release(holder_pid) - elif state is LockHolderState.ORPHAN: - # Подтверждённый зомби (корень цепочки родителей мёртв) — - # завершаем его, дожидаемся фактической смерти, затем - # повторно проверяем lock и забираем. - logger.warning( - f"PID lock orphan holder pid={holder_pid} — terminating zombie" - ) - if not self._terminate_holder(holder_pid): - raise LockBusyError( - f"PID lock orphan pid={holder_pid} не удалось завершить " - f"за {self._terminate_timeout}s — retry позже (fail-closed, " - f"lock не тронут)" - ) - logger.info(f"Zombie pid={holder_pid} terminated, stealing lock") - - # Holder мёртв / lock освобождён / lock битый / зомби завершён — - # забираем lock. TOCTOU-guard (termination/lock race): удаляем файл - # ТОЛЬКО если это всё ещё ТОТ ЖЕ lock, который мы классифицировали. + + # Holder мёртв / lock освобождён / lock битый — забираем lock. + # TOCTOU-guard (death/lock race): удаляем файл ТОЛЬКО если это всё ещё + # ТОТ ЖЕ lock, который мы классифицировали. if self.lock_path.exists(): cur = self._read_holder() same_holder = ( @@ -335,7 +346,7 @@ def acquire(self) -> None: try: self._unlink_with_retry(holder_pid) except PermissionError: - # Windows: файл не освободился за terminate_timeout (живой + # Windows: файл не освободился за unlink_retry_timeout (живой # процесс держит fd) — некража, fail-closed. raise RuntimeError( f"Cannot steal PID lock from pid={holder_pid}: file in use" @@ -399,17 +410,40 @@ def __del__(self): # Holder classification (WS9) # ══════════════════════════════════════════════════════ - def classify_holder(self, pid: int, started: float) -> LockHolderState: + def classify_holder( + self, pid: int, started: float, holder_data: Optional[dict] = None + ) -> LockHolderState: """Классифицирует holder'а lock-файла по интроспекции процесса. - Порядок проверок (см. AC-1..AC-6): - 1. PID мёртв → DEAD (stale, steal). - 2. pid == наш процесс → HEALTHY (self-hold; не может быть PID-reuse). - 3. create_time guard: процесс создан ПОСЛЕ записи lock - (create_time > started + tolerance) → PID-reuse → DEAD. - 4. parent-chain walk: живой Zed в цепочке → HEALTHY; - корень мёртв → ORPHAN; недоступно/неоднозначно → AMBIGUOUS. + R3TF (2026-08-26): «break stale lock only on proof of death». + Живой процесс НИКОГДА не терминейтится — эвристика цепочки + родителей удалена (убивала живые MCP другого окна, инцидент + 2026-08-26). Порядок проверок: + 1. hostname не совпадает (v2) / формат неподдерживаемый → AMBIGUOUS + (PID чужой машины локально непроверяем; непонятный формат — + fail-closed, не трогаем). + 2. PID мёртв → DEAD (stale, steal). + 3. pid == наш процесс → HEALTHY (self-hold; не может быть PID-reuse). + 4. create_time guard: процесс создан ПОСЛЕ записи lock + (create_time > started + tolerance) → PID-reuse/подделка → DEAD. + 5. Живой PID с валидным create_time → HEALTHY (wait, fail-closed). """ + if holder_data is not None: + holder_host = holder_data.get("hostname") + if holder_host and holder_host != socket.gethostname(): + logger.warning( + f"PID lock from foreign host {holder_host!r} " + f"(this host {socket.gethostname()!r}) — cannot verify PID, held" + ) + return LockHolderState.AMBIGUOUS + holder_v = holder_data.get("v") + if holder_v is not None and holder_v != LOCK_FORMAT_VERSION: + logger.warning( + f"PID lock format v{holder_v} unsupported (current v" + f"{LOCK_FORMAT_VERSION}) — held (fail-closed)" + ) + return LockHolderState.AMBIGUOUS + if not self._inspector.is_alive(pid): return LockHolderState.DEAD if pid == os.getpid(): @@ -426,58 +460,20 @@ def classify_holder(self, pid: int, started: float) -> LockHolderState: ) return LockHolderState.DEAD - chain = self._inspector.parent_chain(pid) - if chain is None: - return LockHolderState.AMBIGUOUS - if any(alive and ("Zed" in name) for (_pid, name, alive) in chain): - return LockHolderState.HEALTHY - if chain and not chain[-1][2]: - # Последний элемент цепочки мёртв (корень) и Zed не найден - # → holder осиротел. - return LockHolderState.ORPHAN - return LockHolderState.AMBIGUOUS - - def _terminate_holder(self, pid: int) -> bool: - """Завершает осиротевший процесс и ждёт фактической смерти. - - Windows: TerminateProcess; Unix: os.kill(pid, SIGKILL). - Возвращает True только если процесс реально умер - (не просто «команда отправлена»). - """ - try: - if sys.platform == "win32": - import ctypes - - kernel32 = ctypes.windll.kernel32 - PROCESS_TERMINATE = 0x0001 - handle = kernel32.OpenProcess(PROCESS_TERMINATE, False, pid) - if not handle: - return not self._inspector.is_alive(pid) - try: - kernel32.TerminateProcess(handle, 1) - finally: - kernel32.CloseHandle(handle) - else: - os.kill(pid, 9) # SIGKILL - except Exception: # noqa: BLE001 — диагностика, не критично - return False - - deadline = time.monotonic() + self._terminate_timeout - while time.monotonic() < deadline: - if not self._inspector.is_alive(pid): - return True - time.sleep(0.1) - return not self._inspector.is_alive(pid) + # Живой PID с честным create_time — держим как HELD. + # Parent-chain здесь НЕ участвует: venvwlauncher-цепочки живых MCP + # обрываются на мёртвом предке ДО живого Zed (ложный ORPHAN → kill). + return LockHolderState.HEALTHY def _unlink_with_retry(self, holder_pid: Optional[int]) -> None: """Удаляет lock-файл с retry против Windows PermissionError. - После TerminateProcess процесс формально мёртв, но файловый дескриптор - (os.open в holder'е) может ещё мгновение держаться ядром → unlink - бросает PermissionError. Повторяем до terminate_timeout; по таймауту - пробрасываем PermissionError (fail-closed, некража). + После смерти holder'а файловый дескриптор (os.open в holder'е) может + ещё мгновение держаться ядром → unlink бросает PermissionError. + Повторяем до unlink_retry_timeout; по таймауту пробрасываем + PermissionError (fail-closed, некража). """ - deadline = time.monotonic() + self._terminate_timeout + deadline = time.monotonic() + self._unlink_retry_timeout while True: try: self.lock_path.unlink(missing_ok=True) @@ -492,13 +488,20 @@ def _unlink_with_retry(self, holder_pid: Optional[int]) -> None: # ══════════════════════════════════════════════════════ def _write_owner(self, fd: int) -> None: - """Пишет PID + timestamp в lock-файл и синхронизирует на диск.""" + """Пишет PID + timestamp + hostname (v2) в lock-файл и синхронизирует. + + hostname обязателен (R3TF-атака 6): на сетевом share PID другой + машины непроверяем локально — классификация по hostname даёт + AMBIGUOUS (fail-closed) вместо ложного steal/HEALTHY. + """ self._fd = fd lock_data = json.dumps( { + "v": LOCK_FORMAT_VERSION, "pid": os.getpid(), "started": time.time(), "role": LOCK_ROLE, + "hostname": socket.gethostname(), } ).encode() os.write(fd, lock_data) @@ -519,7 +522,12 @@ def _read_holder(self) -> Optional[dict]: try: with open(self.lock_path, "r", encoding="utf-8") as f: data = json.load(f) - return {"pid": data.get("pid"), "started": data.get("started") or 0} + return { + "pid": data.get("pid"), + "started": data.get("started") or 0, + "hostname": data.get("hostname"), + "v": data.get("v"), + } except (json.JSONDecodeError, OSError): time.sleep(self._poll_interval) logger.warning( diff --git a/src/core/indexing/db_manager.py b/src/core/indexing/db_manager.py index 56cc5cd3..625f920a 100644 --- a/src/core/indexing/db_manager.py +++ b/src/core/indexing/db_manager.py @@ -79,17 +79,20 @@ def __init__( # Диагностика при старте (Задача 3/5): человеческий текст с действием # вместо RuntimeError из глубины при живом владельце lock-а. # Семантика lock-а НЕ меняется — только сообщение об ошибке. + # R3TF (2026-08-26): второе окно Zed на ту же БД НЕ падает при старте — + # переходит в read-only режим (запись будет отвергнута в begin_write). self._startup_issue: Optional[str] = None + self._pid_lock_acquired = False try: self._db_lock.acquire() + self._pid_lock_acquired = True except RuntimeError as _lock_err: self._startup_issue = ( - f"База занята другим процессом MCP. Закройте второе окно Zed " - f"или подождите завершения его индексации. " + f"База занята другим процессом MCP — работаю read-only. " + f"Запись/индексация будут отвергнуты до освобождения lock-а. " f"Детали: {_lock_err}" ) - logger.error(self._startup_issue) - raise + logger.warning(self._startup_issue) self._on_recreate = None @@ -476,6 +479,7 @@ def recreate_table_physical(self) -> bool: if self._db_lock is not None: try: self._db_lock.release() + self._pid_lock_acquired = False except Exception as _lock_release_err: logger.debug(f"recreate_table_physical: lock release warning: {_lock_release_err}") db_root = self.db_path @@ -485,12 +489,19 @@ def recreate_table_physical(self) -> bool: logger.info(f"✅ Physically removed LanceDB DB dir: {db_root}") # Пересоздаём чистую директорию БД (версионная цепочка = 0) Path(to_win_long_path(db_root)).mkdir(parents=True, exist_ok=True) - # Перезахватываем PID-lock на новой директории + # Перезахватываем PID-lock на новой директории. + # R3TF (атака 6): отказ re-acquire НЕ прощается — продолжение + # без lock даёт двух писателей (corruption). Fail-closed: raise. if self._db_lock is not None: try: self._db_lock.acquire() + self._pid_lock_acquired = True except RuntimeError as _lock_racq_err: - logger.warning(f"recreate_table_physical: lock re-acquire failed: {_lock_racq_err}") + self._pid_lock_acquired = False + raise RuntimeError( + f"recreate_table_physical: PID-lock re-acquire failed — " + f"БД пересоздана, но запись без lock невозможна. {_lock_racq_err}" + ) from _lock_racq_err self.reset_connection() return True except PermissionError as _perm_err: @@ -600,7 +611,22 @@ def begin_write(self): Использовать в index_project / drop_table, чтобы search не читал поломанный индекс (паттерн chunkhound SerialDatabaseExecutor). + + R3TF (2026-08-26): перед записью требует PID-lock. Если старт + прошёл в read-only (БД занята другим окном MCP) — повторная попытка + захвата здесь, иначе RuntimeError (fail-closed: запись без lock + означает два писателя → corruption). """ + _ensure_pid_lock = getattr(self, "_pid_lock_acquired", True) is False + if _ensure_pid_lock: + try: + self._db_lock.acquire() + self._pid_lock_acquired = True + except RuntimeError as _lock_err: + raise RuntimeError( + f"Запись в БД невозможна: база занята другим процессом MCP " + f"(read-only режим). {_lock_err}" + ) from _lock_err return self._write_lock # ══════════════════════════════════════════════════════════ diff --git a/src/core/indexing/index_guard.py b/src/core/indexing/index_guard.py index 1b6c439d..1ff85fe8 100644 --- a/src/core/indexing/index_guard.py +++ b/src/core/indexing/index_guard.py @@ -349,18 +349,73 @@ def save_symbol_index(self, symbol_index: any) -> bool: """ try: cache_file = self.db_path / "symbol_index.json" + + # [E4 FIX] Explicit Guard (Уровень 1): SymbolIndexAdapter в MODE_PURE + # хранит символы исключительно в PropertyGraph (graph.db). In-memory + # _definitions пуст. Дамп пустого JSON коррумпирует cold-start + # (0.00 Recall на modify/test/impact/verify). Persistence = graph.db, + # JSON-сейв не нужен и вреден. + if getattr(symbol_index, "_mode", None) == "pure": + logger.debug( + "save_symbol_index: MODE_PURE adapter — graph.db is persistence, skip JSON" + ) + return True + + # [E4 FIX] Anti-corruption Backup (Уровень 2): если _definitions пуст, + # но граф содержит узлы (graph-backed instance любого режима без + # in-memory дублей) — не пишем пустой JSON поверх данных на диске. + if ( + not getattr(symbol_index, "_definitions", None) + and hasattr(symbol_index, "graph") + and symbol_index.graph is not None + and symbol_index.graph.count_nodes() > 0 + ): + logger.debug( + "save_symbol_index: empty _definitions but graph has nodes — skip JSON corruption" + ) + return True + + # Legacy anti-corruption guard (инцидент 2026-08-26): отсутствие + # словарей _definitions/_references/_file_to_symbols у graph-backed + # инстанса (без .graph-атрибута) — graph.db есть persistence. + if not all( + hasattr(symbol_index, a) + for a in ("_definitions", "_references", "_file_to_symbols") + ): + logger.debug( + "save_symbol_index: graph-backed instance — graph.db is the persistence, skip JSON" + ) + return True + + defs = { + k: [r.to_dict() for r in v] + for k, v in symbol_index._definitions.items() + } + refs = { + k: [r.to_dict() for r in v] + for k, v in symbol_index._references.items() + } + fts = {k: list(v) for k, v in symbol_index._file_to_symbols.items()} + + # Не перезаписывать непустой файл пустым состоянием (коррупция холодного старта). + if not defs and not refs and not fts and cache_file.exists(): + try: + existing = json.loads(cache_file.read_text(encoding="utf-8")) + if any( + (existing.get(k) or {}) and len(existing.get(k) or {}) > 0 + for k in ("definitions", "references", "file_to_symbols") + ): + logger.warning( + f"save_symbol_index: skip EMPTY overwrite (existing file has data) — {cache_file}" + ) + return True + except Exception: + pass # повреждённый файл — даём перезаписать + data = { - "definitions": { - k: [r.to_dict() for r in v] - for k, v in symbol_index._definitions.items() - }, - "references": { - k: [r.to_dict() for r in v] - for k, v in symbol_index._references.items() - }, - "file_to_symbols": { - k: list(v) for k, v in symbol_index._file_to_symbols.items() - }, + "definitions": defs, + "references": refs, + "file_to_symbols": fts, "saved_at": datetime.now().isoformat(), } cache_file.write_text( diff --git a/src/core/indexing/index_status.py b/src/core/indexing/index_status.py index 38d9bd71..a29bb2c4 100644 --- a/src/core/indexing/index_status.py +++ b/src/core/indexing/index_status.py @@ -23,11 +23,17 @@ class IndexStatusReporter: """Собирает статистику индекса: чанки, файлы, stale-проверка.""" def __init__(self, table, project_path: Path, file_guard, watchdog_callback, - table_write_lock=None): + table_write_lock=None, reindex_check=None): self.table = table self.project_path = project_path self.file_guard = file_guard self._watchdog_callback = watchdog_callback + # reindex_check: callable → bool. Если reindex в процессе — get_status() + # обязан МГНОВЕННО вернуть кэш, а не блокировать event loop на + # _table_write_lock, который begin_write() держит весь reindex + # (инцидент 2026-08-25: full reindex ~7.5 мин заморозил ВСЕ MCP-вызовы, + # включая debug_runtime_passport — get_status() на loop-потоке ждал lock). + self._reindex_check = reindex_check # P2-14 audit: lock для чтений таблицы (сериализация с reset_connection) self._table_write_lock = table_write_lock self._cached_total_chunks = 0 @@ -43,7 +49,35 @@ def get_status(self) -> Dict[str, Any]: Всегда сверяет кэш с реальным count_rows() — stale cache не должен показывать данные, которых нет в таблице. + + Reindex fast-fail (инцидент 2026-08-25): пока idёт переиндексация, + begin_write() держит _table_write_lock минуты. Это СИНХРОННЫЙ метод, + вызываемый из loop-потока (intel_get_runtime_status, require_ready_project, + ProjectContext) — блокировка на lock замораживает event loop и все + MCP-вызовы. Возвращаем last-known-good кэш мгновенно (chunkhound guard). """ + # ⚠️ строго `is True`: MagicMock-truthy trap (инцидент 2026-08-13) — + # в тестах reindex_check может быть MagicMock, чей вызов truthy. + if self._reindex_check is not None and callable(self._reindex_check): + try: + if self._reindex_check() is True: + logger.debug( + "get_status: reindex in progress — fast-fail (cached status)" + ) + return { + "total_chunks": self._cached_total_chunks, + "unique_files": len(self._cached_unique_files), + "total_files": len(self._cached_unique_files) or 0, + "stale_files": 0, + "missing_files": 0, + "status": "reindexing", + "watchdog": self._watchdog_callback() + if self._watchdog_callback + else {}, + "reindex_in_progress": True, + } + except Exception as _reidx_err: # noqa: BLE001 — статус не роняем + logger.debug(f"get_status reindex-check failed: {_reidx_err}") try: # P2-14 audit: чтения count_rows/to_lance под _table_write_lock, # чтобы get_status не читал таблицу в момент reset_connection diff --git a/src/core/indexing/indexer.py b/src/core/indexing/indexer.py index 660171bc..86335878 100644 --- a/src/core/indexing/indexer.py +++ b/src/core/indexing/indexer.py @@ -138,6 +138,14 @@ def __init__( file_guard=self.file_guard, watchdog_callback=self.watchdog_status, table_write_lock=self._table_write_lock, + # Reindex fast-fail (инцидент 2026-08-25): get_status() обязан + # вернуть кэш мгновенно, пока begin_write() держит _write_lock + # весь reindex — иначе event loop замерзает на минуты. + reindex_check=lambda: bool( + getattr(self, "db_manager", None) + and callable(getattr(self.db_manager, "is_reindexing", None)) + and self.db_manager.is_reindexing() is True + ), ) self._cached_total_chunks = self._status_reporter._cached_total_chunks self._cached_unique_files = self._status_reporter._cached_unique_files diff --git a/src/core/indexing/parser.py b/src/core/indexing/parser.py index 6734d773..47e73885 100644 --- a/src/core/indexing/parser.py +++ b/src/core/indexing/parser.py @@ -577,7 +577,7 @@ def _walk_node( symbols.append( { "name": full_symbol, - "line": node.start_point[0], + "line": node.start_point[0] + 1, "kind": node.type, "signature": signature, "docstring": docstring, @@ -627,8 +627,8 @@ def _walk_node( "text": prefix + text, "text_compact": prefix + compact_text, "file": str(file_path), - "start_line": node.start_point[0], - "end_line": node.end_point[0], + "start_line": node.start_point[0] + 1, + "end_line": node.end_point[0] + 1, "type": node.type, "context": current_context, "symbol_name": symbol_name, @@ -1004,7 +1004,7 @@ def extract_definitions_scm(self, file_path: Path) -> List[Dict]: signature, docstring = self._get_signature_and_docstring(node, code) symbols.append({ "name": name, - "line": node.start_point[0], + "line": node.start_point[0] + 1, "kind": node.type, "signature": signature, "docstring": docstring, @@ -1120,7 +1120,7 @@ def _extract_calls_recursive( { "caller": current_function, "callee": callee_name, - "line": node.start_point[0], + "line": node.start_point[0] + 1, "file": str(file_path), } ) @@ -1456,7 +1456,7 @@ def _extract_imports_recursive( { "source_file": str(file_path), "target_module": module_name, - "line": node.start_point[0], + "line": node.start_point[0] + 1, "text": import_text.strip(), } ) @@ -1640,7 +1640,7 @@ def _extract_assignments_recursive( assigned=assigned, assignments=assignments, file_path=file_path, - line=node.start_point[0], + line=node.start_point[0] + 1, function=current_function, condition_path=list(condition_path), scope_id=scope_id, @@ -1762,8 +1762,8 @@ def _chunk_giant_text( "text": f"{prefix}// [Part {part_num}]\n{text}", "text_compact": f"{prefix}// [Part {part_num}]\n{compact}", "file": file_path, - "start_line": start_line_offset + i, - "end_line": start_line_offset + i + len(chunk_lines) - 1, + "start_line": start_line_offset + i + 1, + "end_line": start_line_offset + i + len(chunk_lines), "type": "giant_function_part", "context": context, "symbol_name": "", diff --git a/src/core/intelligence/layer.py b/src/core/intelligence/layer.py index 1bc5e5e5..f1003abd 100644 --- a/src/core/intelligence/layer.py +++ b/src/core/intelligence/layer.py @@ -426,8 +426,13 @@ async def intel_get_runtime_status(self) -> Dict[str, Any]: # INC-6BCB-v3.1: late-resolve. active_indexer = self._resolve_active_indexer() + # get_status() — СИНХРОННЫЙ и может блокировать (lock БД / stale-scan). + # Инцидент 2026-08-25: full reindex держал _write_lock ~7.5 мин, + # get_status() на loop-потоке заморозил ВСЕ MCP-вызовы (таймауты + # вкл. debug_runtime_passport). Выносим в поток: loop свободен, + # а IndexStatusReporter сам fast-fail-ит при is_reindexing=True. status = ( - active_indexer.get_status() + await asyncio.to_thread(active_indexer.get_status) if hasattr(active_indexer, "get_status") else {} ) @@ -438,6 +443,29 @@ async def intel_get_runtime_status(self) -> Dict[str, Any]: status.get("total_files", 0) if isinstance(status, dict) else 0 ) + # Reindex-состояние (Вариант А, 2026-08-25): при is_reindexing=True + # get_status() возвращает кэш + status="reindexing". Пробрасываем + # флаг и прогресс в index_telemetry, чтобы агент видел «🔄 Reindex + # в процессе (N%)» вместо ложного «0 chunks» (live-прогон показал: + # во время full reindex статус врал «индекс пуст» → агент мог + # запустить ненужный второй reindex). + _reindexing = bool( + isinstance(status, dict) and status.get("reindex_in_progress") is True + ) + _reindex_progress_pct = None + _reindex_eta_sec = None + if _reindexing: + try: + _job_id = self.get_active_reindex_job_id() + if _job_id: + _job = job_manager.get_job(_job_id) + if _job and _job.status == "running": + _enriched = self._enrich_job_response(_job) + _reindex_progress_pct = round(_job.progress * 100) + _reindex_eta_sec = _enriched.get("estimated_seconds") + except Exception as _reidx_err: # noqa: BLE001 — статус не роняем + logger.debug(f"reindex progress enrich failed: {_reidx_err}") + # Project path (может быть != self.project_path если был fallback). active_path = ( str(active_indexer.project_path) @@ -547,7 +575,15 @@ async def intel_get_runtime_status(self) -> Dict[str, Any]: "symbol_index_count": _resolve_symbol_count( active_indexer, total_chunks ), - "status": "active" if total_chunks > 0 else "empty", + # Reindex-состояние для агента (Вариант А): вместо вранья + # «0 chunks» при переиндексации показываем честный статус + # и прогресс, чтобы агент ждал, а не запускал 2-й reindex. + "status": "reindexing" + if _reindexing + else ("active" if total_chunks > 0 else "empty"), + "reindex_in_progress": _reindexing, + "reindex_progress_pct": _reindex_progress_pct, + "reindex_eta_sec": _reindex_eta_sec, }, # Consistency Engine (WS2): состояния артефактов — аддитивно. "consistency": { @@ -755,6 +791,32 @@ def _index_progress_callback( job.ended_at = time.time() job.result = {"files_processed": "Индексация завершена", "status": "ok"} + # State machine (косметический баг 2026-08-26): get_indexer() + # ставит INDEXING при пустом индексе; после успешного reindex + # обязателен перевод в READY — иначе паспорт вечно показывает + # «Project State: INDEXING» и wait_until_ready ждёт до таймаута. + try: + _reg = ( + self._services.resolve( + __import__( + "src.core.di_container", + fromlist=["ProjectIndexerRegistry"], + ).ProjectIndexerRegistry + ) + if self._services is not None + else None + ) + if _reg is not None and hasattr(_reg, "set_state"): + _reg.set_state( + self.project_path, + __import__( + "src.core.indexing.project_indexer_registry", + fromlist=["ProjectState"], + ).ProjectState.READY, + ) + except Exception as _state_err: # noqa: BLE001 — статус не роняем + logger.debug(f"reindex set_state READY failed: {_state_err}") + # Consistency Engine (WS2): индексация завершена успешно. try: from src.core.consistency import get_consistency_tracker @@ -933,9 +995,16 @@ async def intel_get_project_memory( self, include_retracted: bool = False, verify_on_read: bool = True, + project_root: Optional[str] = None, ) -> Tuple[Dict[str, List[Dict]], Dict[str, Any]]: """Получить полную карту памяти проекта + ресипт VOR-проверки. + R3TF (2026-08-26, multi-window): project_root позволяет читать память + ЦЕЛЕВОГО проекта, а не только self.project_path (CWD/DI). layer — core- + модуль, не может импортировать mcp-резолвер active/CWD; явный project_root + пробрасывается из тула (mcp-слой), который резолвит проект сам. + Если project_root не задан — используется self.store (поведение до фикса). + ADR-0002: REFUTED-узлы скрыты по умолчанию; include_retracted=True возвращает их для аудита и отладки. ADR-0003: verify_on_read=True (по умолчанию) — ACTIVE-узлы проходят @@ -956,11 +1025,28 @@ async def intel_get_project_memory( stats["metrics"] — store.memory_metrics(): распределение статусов и false_retraction_rate (снятие метрик без отдельного тула). """ - memory = self.store.load_memory(include_retracted=include_retracted) + # R3TF: выбираем store целевого проекта при явном project_root. + target_path: Path = self.project_path + store: "IntelligenceStore" = self.store + if project_root and project_root.strip(): + try: + target_path = Path(project_root).resolve() + store = IntelligenceStore(target_path) + except Exception: + logger.warning( + "intel_get_project_memory: не удалось открыть store для %s, " + "используется self.store (%s)", + project_root, + self.project_path, + ) + target_path = self.project_path + store = self.store + + memory = store.load_memory(include_retracted=include_retracted) if verify_on_read and not include_retracted: from src.core.intelligence.verify_on_read import get_verifier - verifier = get_verifier(self.project_path, self.store, self._write_lock) + verifier = get_verifier(target_path, store, self._write_lock) memory, stats = await asyncio.to_thread(verifier.run, memory) # Помечаем INCONCLUSIVE узлы флагом для выдачи агенту inconclusive_ids = set(stats.get("inconclusive_nodes", [])) @@ -990,7 +1076,7 @@ async def intel_get_project_memory( node.setdefault("verification", "budget_exceeded") else: stats = {"verify_on_read": False} - stats["metrics"] = self.store.memory_metrics() + stats["metrics"] = store.memory_metrics() return memory, stats def _load_flat_memory_nodes(self) -> List[Dict]: diff --git a/src/core/intelligence/project_context.py b/src/core/intelligence/project_context.py index 0a506fff..1a683818 100644 --- a/src/core/intelligence/project_context.py +++ b/src/core/intelligence/project_context.py @@ -21,6 +21,7 @@ from __future__ import annotations +import asyncio import logging import os import time @@ -174,7 +175,10 @@ async def capture(self) -> ProjectContextSnapshot: ) # ─── State + Index (Registry) ─────────────────────── - snap = self._capture_registry(snap) + # Синхронная работа (get_status() → count_rows + stale os.walk) — + # выносим в поток: инцидент 2026-08-25 — get_status() на loop-потоке + # заморозил все MCP-вызовы на ~7.5 мин во время full reindex. + snap = await asyncio.to_thread(self._capture_registry, snap) # ─── Bridge (LSP) ──────────────────────────────────── snap = self._capture_bridge(snap) diff --git a/src/core/intelligence/tools_reg.py b/src/core/intelligence/tools_reg.py index 895ef5ee..44575e1e 100644 --- a/src/core/intelligence/tools_reg.py +++ b/src/core/intelligence/tools_reg.py @@ -222,10 +222,18 @@ async def reset_index() -> str: if _lock_released: try: _dbm._db_lock.acquire() + if hasattr(_dbm, "_pid_lock_acquired"): + _dbm._pid_lock_acquired = True except Exception as _reacq_err: - logger.warning( - f"reset_index: PID-lock re-acquire failed: {_reacq_err}" - ) + if hasattr(_dbm, "_pid_lock_acquired"): + _dbm._pid_lock_acquired = False + # R3TF (атака 6): продолжение без lock = два + # писателя → fail-closed. + raise RuntimeError( + f"reset_index: PID-lock re-acquire failed — " + f"БД очищена, но запись без lock невозможна. " + f"{_reacq_err}" + ) from _reacq_err _dbm.reset_connection() else: _dbm._switch_to_fresh_path() @@ -382,6 +390,7 @@ async def get_project_memory( include_retracted: bool = False, verify_on_read: bool = True, limit: int = 3, + project_root: str = "", ) -> str: """Получить карту памяти проекта (Архитектурные решения ADR, Технический долг, Известные костыли). @@ -389,11 +398,15 @@ async def get_project_memory( ADR-0003: verify_on_read=True (по умолчанию) — ленивая проверка ACTIVE-узлов при чтении (SILENT_ABSENCE -> REFUTED, найденные -> VERIFIED); False — отключить для отладки. limit: сколько узлов секции показывать в сводке (0 — показать все; аудит/полный список). + project_root: явный путь к целевому проекту (multi-window) — читать память + ИМЕННО этого проекта, а не активного/CWD (R3TF, 2026-08-26). Вывод включает VOR-ресипт: checked/total узлов, бюджет, latency — потребитель видит, сколько реально проверено в этом чтении (пол: измерение ниже пола — преждевременно). """ memory, stats = await intel_layer.intel_get_project_memory( - include_retracted=include_retracted, verify_on_read=verify_on_read + include_retracted=include_retracted, + verify_on_read=verify_on_read, + project_root=project_root.strip() if project_root and project_root.strip() else None, ) from src.utils.ui_formatter import format_project_memory diff --git a/src/core/log_manager.py b/src/core/log_manager.py index 049b56a9..f59666dd 100644 --- a/src/core/log_manager.py +++ b/src/core/log_manager.py @@ -63,7 +63,8 @@ def setup_project_logging( project_path: Path, level: str = "INFO", project_label: Optional[str] = None, -) -> logging.Logger: + attach: bool = True, +) -> "logging.Logger | RotatingFileHandler": """Настраивает файловое логирование для конкретного проекта. Создаёт RotatingFileHandler в .codebase_indices/logs/.log @@ -73,19 +74,25 @@ def setup_project_logging( project_path: Корневая директория проекта level: Уровень логирования (DEBUG, INFO, WARNING, ERROR) project_label: Метка проекта (если None — имя директории) + attach: Если True — подключает хендлер к логгерам (старое поведение). + Если False — только возвращает настроенный RotatingFileHandler, + чтобы вызывающий мог встроить его в QueueListener (неблокирующее + логирование, см. main.setup_logging). Returns: - Корневой логгер проекта + attach=True: корневой логгер проекта. + attach=False: настроенный RotatingFileHandler. """ if project_label is None: project_label = project_path.name - # Защита от повторной инициализации - project_key = str(project_path.resolve()) - if project_key in _initialized_projects: - return logging.getLogger("mscodebase") + if attach: + # Защита от повторной инициализации + project_key = str(project_path.resolve()) + if project_key in _initialized_projects: + return logging.getLogger("mscodebase") - _initialized_projects.add(project_key) + _initialized_projects.add(project_key) log_dir = get_log_dir(project_path) log_file = log_dir / f"{project_label}.log" @@ -109,6 +116,10 @@ def setup_project_logging( log_level = getattr(logging, level.upper(), logging.INFO) handler.setLevel(log_level) + if not attach: + # Только возвращаем хендлер — без подключения к логгерам и без очистки. + return handler + # Подключаем к корневому логгеру mscodebase root_logger = logging.getLogger("mscodebase") root_logger.setLevel(log_level) diff --git a/src/core/search/engine.py b/src/core/search/engine.py index 3773a1c5..3791fd46 100644 --- a/src/core/search/engine.py +++ b/src/core/search/engine.py @@ -555,6 +555,16 @@ async def hybrid_search_async( if not query or not query.strip(): return [] + # ── Symbol Graph Path (Вариант А, Step 2): deterministic short-circuit ── + # Для идентификатор-запросов граф даёт точную локацию за ~6.8ms и полностью + # заменяет векторный поиск (~3600ms). NL-запросы → graph [], идём вниз. + _q_strip = query.strip() + if _IDENTIFIER_QUERY_RE.match(_q_strip): + _g = self._graph_stage(_q_strip, limit, layer=layer) + if _g: + logger.debug(f"[graph_stage] short-circuit '{_q_strip}': {len(_g)} refs") + return _g + # Строим filter_expr для LanceDB (если указан layer) # Экранируем layer через _escape_sql_value, т.к. LanceDB # не поддерживает параметризованные запросы (см. indexer_table.py) @@ -661,9 +671,16 @@ async def hybrid_search_async( seen_keys.add(key) unique_bm25.append(r) + # Symbol Graph Path (Вариант А): детерминированный бакет в RRF. + # Для NL/сложных запросов graph_results=[] → поведение идентично 3-way. + # Пропускаем граф-вызов для чистого NL (экономим ~6ms на каждом поиске). + _symbolish = bool(re.search(r"[_\.::/]", query)) or _IDENTIFIER_QUERY_RE.match(_q_strip) + graph_results = self._graph_stage(query, raw_limit, layer=layer) if _symbolish else [] + if use_rrf: rrf_results = reciprocal_rank_fusion_3way( - unique_bm25, all_dense_results, all_fts5_results, raw_limit + unique_bm25, all_dense_results, all_fts5_results, raw_limit, + graph_results=graph_results, ) if tracer: tracer.record_rrf(rrf_results) @@ -1049,8 +1066,27 @@ def search_with_mode( # топ вместо самого кода (Эмпир.: fast "PropagationEngine" → 5 doc + 1 code). results = _prepend_code_name_matches(results, fts5_raw, query, limit) + # Symbol Graph Path (Вариант А, Step 2): детерминированные symbol-указатели + # поверх fast-конвейера (short-circuit для identifier уже в hybrid-пути; + # здесь — merge, т.к. fast-mode опционален и лёгкий). + _gs = self._graph_stage(query, limit, layer=layer) + if _gs: + _have = { + (r.get("metadata", {}).get("file"), + r.get("metadata", {}).get("symbol_name")) + for r in results + } + _fresh = [ + r for r in _gs + if (r["metadata"]["file"], r["metadata"]["symbol_name"]) not in _have + ] + if _fresh: + results = _fresh + results + # Graph context expansion для fast mode (Задача 5/5): связи из # графа вызовов (callers/callees) видны агенту без reranker'а. + # Запускается ПОСЛЕ graph-merge, чтобы единым проходом обогатить + # и symbol-указатели (Вариант А), и кодовые чанки. # Стоимость измерена ~7ms на 10 результатов (SQLite граф). t1 = time.perf_counter() results = self._expand_graph_context(results, query) @@ -1181,6 +1217,72 @@ def context_search(self, selected_code: str, limit: int = 5) -> str: + def _layer_from_path(self, file_path: str) -> str: + """Эвристический слой из пути файла (для metadata граф-результатов).""" + p = (file_path or "").replace("\\", "/") + if "/tests/" in p: + return "tests" + for layer in ("core", "mcp", "providers", "utils", "interfaces", + "sources", "adapters", "cli", "plugins"): + if f"/{layer}/" in p: + return layer + return "src" + + def _graph_stage(self, query: str, limit: int, layer: Optional[str] = None) -> List[dict]: + """Symbol Graph Path (Вариант А, Step 2): детерминированный первичный retrieval. + + Обходит PropertyGraph (`SymbolIndexAdapter.search_symbols`) и возвращает + SymbolRef-указатели (symbol/file/line/kind) как search-result dict. + + Свойства: + - Для NL-запросов search_symbols возвращает [] (не загрязняет выдачу) — Proof of Origin. + - Граф недоступен (None / нет атрибута) → [] (graceful degradation). + - Если указан `layer`, фильтрует результаты по слою. + - Защита от перематчинга: сначала определения (is_definition), потом использования. + """ + try: + si = getattr(self.indexer, "_symbol_index", None) + if si is None: + si = getattr(self.indexer, "symbol_index", None) + if si is None or not hasattr(si, "search_symbols"): + return [] + refs = si.search_symbols(query, top_k=limit) + if not refs: + return [] + # Сортируем: определения выше использований (защита от перематчинга) + refs_sorted = sorted(refs, key=lambda r: 0 if r.is_definition else 1) + out: List[dict] = [] + for ref in refs_sorted[:limit]: + ref_layer = self._layer_from_path(ref.file_path) + if layer and ref_layer != layer: + continue + out.append({ + "text": f"{ref.kind} {ref.symbol}\n📍 {ref.file_path}:{ref.line}", + "metadata": { + "file": ref.file_path, + # Sentinel chunk_index: гарантированно уникален vs реальных + # чанков (положительные int) → не коллайдит в RRF-ключе. + "chunk_index": -(10_000_000 + ref.line), + "layer": ref_layer, + "symbol": ref.symbol, + "symbol_name": ref.symbol, + "line": ref.line, + "kind": ref.kind, + "graph_stage": True, + "is_symbol_ref": True, + "is_definition": ref.is_definition, + }, + "bm25_score": 0.0, + "dense_score": 0.0, + "fts5_score": 0.0, + "graph_score": 1.0 if ref.is_definition else 0.5, + "final_score": 1.0 if ref.is_definition else 0.5, + }) + return out + except Exception as e: + logger.debug(f"graph_stage error: {e}") + return [] + def _expand_graph_context( self, results: List[dict], original_query: str ) -> List[dict]: @@ -1215,21 +1317,26 @@ def _expand_graph_context( return results for r in results[:10]: # только топ-10 результатов - text = r.get("text", "") - if not text: - continue - - # Извлекаем имя символа из текста чанка - name = _extract_symbol_name(text) - if not name or len(name) < 2: - continue - meta = r.get("metadata", {}) if not isinstance(meta, dict): meta = {} + # Имя символа: приоритет — из metadata (graph-stage / symbol-указатели), + # иначе извлекаем из текста чанка (обратная совместимость). + name = ( + meta.get("symbol_name") + or meta.get("symbol") + or _extract_symbol_name(r.get("text", "")) + ) + if not name or len(name) < 2: + continue + # Находим callers (кто вызывает этот символ) refs = si.find_references(name) + if not refs and "." in name: + # qualified-имя не резолвится (адаптер ключует reference по + # bare-имени) → фолбэк на последний компонент. + refs = si.find_references(name.rsplit(".", 1)[-1]) callers_list = [] for ref in refs[:3]: if not ref.is_definition and ref.symbol != name: diff --git a/src/core/search/scoring.py b/src/core/search/scoring.py index 081100c2..5a3f007b 100644 --- a/src/core/search/scoring.py +++ b/src/core/search/scoring.py @@ -96,6 +96,7 @@ def reciprocal_rank_fusion_3way( fts5_results: List[dict], limit: int = 5, rrf_k: int = 60, + graph_results: Optional[List[dict]] = None, ) -> List[dict]: """3-way RRF: объединяет BM25 + dense + FTS5. @@ -124,6 +125,10 @@ def _ingest(results, score_key): _ingest(bm25_results, "bm25_score") _ingest(dense_results, "dense_score") _ingest(fts5_results, "fts5_score") + # Symbol Graph Path (Вариант А, Step 2): граф — 4-й независимый источник рангов. + # graph_results не содержит dense-вектор, поэтому graph_score=0 для остальных. + if graph_results: + _ingest(graph_results, "graph_score") sorted_keys = sorted(scores.keys(), key=lambda k: (-scores[k], k))[:limit] @@ -137,6 +142,7 @@ def _ingest(results, score_key): "bm25_score": result["bm25_score"], "dense_score": result["dense_score"], "fts5_score": result.get("fts5_score", 0.0), + "graph_score": result.get("graph_score", 0.0), "final_score": scores[key], } ) diff --git a/src/main.py b/src/main.py index 21953578..dd2e4fea 100644 --- a/src/main.py +++ b/src/main.py @@ -3,12 +3,18 @@ """ import logging +import logging.handlers import os +import queue import sys from pathlib import Path logger = logging.getLogger("MSCodebase") +# Хранится на уровне модуля, чтобы QueueListener не был собран GC за время +# работы процесса (неблокирующее логирование, см. setup_logging). +_LOG_LISTENER = None + # Определяем PROJECT_ROOT: # Если Python запущен из расширения -> корень расширения # Иначе -> родитель родителя __file__ @@ -109,22 +115,50 @@ def setup_logging(): sys.stdout = sys.stderr level = os.getenv("LOG_LEVEL", "INFO").upper() - logging.basicConfig( - level=getattr(logging, level, logging.INFO), - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", - stream=sys.stderr, - ) + log_level = getattr(logging, level, logging.INFO) + fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + datefmt = "%H:%M:%S" + + # 3. Неблокирующее логирование через очередь (QueueListener). + # Блокирующая запись в stderr/файл из event-loop потока держит + # logging._lock и замораживает цикл (root cause реидексации, py-spy). + root = logging.getLogger() + for h in list(root.handlers): + root.removeHandler(h) - # 3. Подключаем файловое логирование (проект определится позже в create_mcp_server) + log_queue: "queue.Queue[logging.LogRecord]" = queue.Queue(-1) + + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler.setFormatter(logging.Formatter(fmt, datefmt)) + + file_handler = None try: from src.core.log_manager import setup_project_logging + ext_root = Path(__file__).resolve().parent.parent - # Фиксированное имя лог-файла для единообразия (см. MAIN_LOG_FILE в log_manager.py) - setup_project_logging(ext_root, project_label="mscodebase-intelligence") + # attach=False — получаем RotatingFileHandler, не подключаем напрямую + file_handler = setup_project_logging( + ext_root, project_label="mscodebase-intelligence", attach=False + ) except Exception: pass # Файловое логирование опционально + listeners = [stderr_handler] + if file_handler is not None: + listeners.append(file_handler) + + listener = logging.handlers.QueueListener( + log_queue, *listeners, respect_handler_level=True + ) + listener.start() + + queue_handler = logging.handlers.QueueHandler(log_queue) + root.addHandler(queue_handler) + root.setLevel(log_level) + + global _LOG_LISTENER + _LOG_LISTENER = listener + # 4. Фоновая очистка устаревших артефактов (GC) — не блокирует старт. try: import threading diff --git a/src/mcp/server_factory.py b/src/mcp/server_factory.py index bb9d6fb7..1fd2104e 100644 --- a/src/mcp/server_factory.py +++ b/src/mcp/server_factory.py @@ -510,6 +510,21 @@ async def _delayed_auto_index(services): try: c = await asyncio.to_thread(indexer.index_project, indexer.project_path) logger.info(f"✅ Auto-index: completed ({c} files)") + # State machine: get_indexer() ставил INDEXING при пустом индексе — + # после успешной авто-индексации переводим в READY, иначе паспорт + # вечно показывает «Project State: INDEXING» (косметический баг, + # 2026-08-26; заодно разблокирует wait_until_ready). + try: + from src.core.di_container import ProjectIndexerRegistry as _PIRKey + from src.core.indexing.project_indexer_registry import ( + ProjectState as _PS, + ) + + _reg = services.resolve(_PIRKey) + if _reg is not None and hasattr(_reg, "set_state"): + _reg.set_state(indexer.project_path, _PS.READY) + except Exception as _state_err: # noqa: BLE001 — статус не роняем + logger.debug(f"auto-index set_state READY failed: {_state_err}") finally: if _dbm is not None and hasattr(_dbm, "clear_reindexing"): _dbm.clear_reindexing() diff --git a/src/mcp/tools/base.py b/src/mcp/tools/base.py index 708d0c09..e7d9f1c5 100644 --- a/src/mcp/tools/base.py +++ b/src/mcp/tools/base.py @@ -22,6 +22,7 @@ from __future__ import annotations +import asyncio import logging from abc import ABC, abstractmethod from pathlib import Path @@ -388,7 +389,42 @@ async def require_ready_project( # Проверяем, что индекс не пуст try: indexer = self.resolve_indexer(explicit_project_root) - status = indexer.get_status() + # get_status() синхронный и может блокировать на lock БД + # (инцидент 2026-08-25: full reindex замораживал event loop + # минутами; IndexStatusReporter fast-fail-ит при is_reindexing, + # to_thread — страховка для не-reindex транзиентных write-окон). + status = await asyncio.to_thread(indexer.get_status) + # Reindex-состояние (Вариант А, 2026-08-25): при переиндексации + # get_status() возвращает кэш + status="reindexing". Если молчать, + # агент увидит «Index is empty → run index_project_dir» и запустит + # ВТОРОЙ reindex. Честный fast-fail: «жди, переиндексация идёт». + # ⚠️ строго `is True`: MagicMock-truthy trap (инцидент 2026-08-13) + # сломал бы все тулы в тестах (status.get → truthy MagicMock). + _reindexing = ( + isinstance(status, dict) and status.get("reindex_in_progress") is True + ) + if _reindexing: + _pct = status.get("reindex_progress_pct") + _eta = status.get("reindex_eta_sec") + _tail = ( + f" ({_pct}%" + + (f", ETA ~{_eta // 60}m {_eta % 60}s" if _eta is not None else "") + + ")" + if _pct is not None + else "" + ) + raise ToolError( + status="warning", + message=( + f"⏳ Index is being reindexed{_tail} — " + "retry in a few seconds." + ), + detail=( + "Reindex in progress: search/index tools fast-fail until it " + "finishes. Poll intel_get_job_status for progress." + ), + recoverable=True, + ) if status.get("total_chunks", 0) == 0: raise IndexNotReadyError( detail=( @@ -398,6 +434,11 @@ async def require_ready_project( ) except IndexNotReadyError: raise + except ToolError: + # Reindex-состояние (и другие целевые ToolError) — пробрасываем + # как есть, со status/recoverable, а НЕ заворачиваем в «Failed to + # check index status» (иначе агент теряет <>-семантику). + raise except Exception as e: raise ToolError( status="error", diff --git a/src/mcp/tools/graph_tools.py b/src/mcp/tools/graph_tools.py index f65e33aa..4fb44e3c 100644 --- a/src/mcp/tools/graph_tools.py +++ b/src/mcp/tools/graph_tools.py @@ -145,6 +145,22 @@ class GraphQueryTool(MCPTool): def __init__(self, services: ServiceCollection): super().__init__(services, tool_name="graph_query") + def _resolve_pg(self, project_root: str = "") -> "PropertyGraph": + """Резолвит PropertyGraph для effective project (explicit override ИЛИ active).""" + from src.core.artifact_paths import get_graph_db_path + from src.core.graph import PropertyGraph + + if project_root and project_root.strip(): + candidate = get_graph_db_path(Path(project_root).resolve()) + if candidate.exists(): + return PropertyGraph(candidate) + # Fallback: активный проект (DI default = текущий MCP-проект сессии) + try: + return self._services.resolve(PropertyGraph) + except KeyError: + indexer = self.resolve_indexer() + return PropertyGraph(get_graph_db_path(indexer.project_path)) + @error_boundary("graph_query", timeout_ms=15000) async def execute( self, @@ -154,6 +170,7 @@ async def execute( query: str = "", name: str = "", kwargs: Optional[Dict[str, Any]] = None, + project_root: str = "", ) -> dict: """Мультиплексированный графовый запрос. @@ -161,33 +178,38 @@ async def execute( клиент не мог передать Cypher-запрос или имя переменной («query is required» / «name is required» при пустом target). Теперь параметры в схеме; `target` остаётся как backward-compat. + + project_root (multi-window): если задан — графовые операции + биндятся к этому проекту, иначе к активному проекту MCP-сессии. """ + _project_root = project_root or (kwargs or {}).get("project_root", "") if action == "cypher": - return await self._execute_cypher(query or target, kwargs) + return await self._execute_cypher(query or target, kwargs, project_root=_project_root) elif action == "related": - return await self._execute_related(target, kwargs) + return await self._execute_related(target, kwargs, project_root=_project_root) elif action == "path": - return await self._execute_path(target, kwargs) + return await self._execute_path(target, kwargs, project_root=_project_root) elif action == "flow": - return await self._execute_flow(name or target, kwargs) + return await self._execute_flow(name or target, kwargs, project_root=_project_root) elif action == "drift": - return await self._execute_arch_drift(target) + return await self._execute_arch_drift(target, project_root=_project_root) elif action == "verify": - return await self._execute_verify(target, kwargs) + return await self._execute_verify(target, kwargs, project_root=_project_root) else: # По умолчанию — GraphRAG (action="query") - return await self._execute_query(query_type or "impact", target, kwargs) + return await self._execute_query(query_type or "impact", target, kwargs, project_root=_project_root) async def _execute_query( - self, query_type: str, target: str, kwargs: Optional[Dict[str, Any]] = None + self, query_type: str, target: str, kwargs: Optional[Dict[str, Any]] = None, + project_root: str = "", ) -> dict: """GraphRAG: impact/feature/deps/tests запросы к графу знаний.""" from src.core.graph_rag import GraphRAGQueryEngine - indexer = self.resolve_indexer() + indexer = self.resolve_indexer(explicit_project_root=project_root or None) engine = GraphRAGQueryEngine( indexer.project_path, - symbol_index=self.resolve_symbol_index(), + symbol_index=self.resolve_symbol_index(explicit_project_root=project_root or None), ) if query_type == "impact": @@ -254,27 +276,17 @@ async def _execute_query( return {"status": "error", "action": "query", "message": f"Unknown query_type: {query_type}. {hint}"} async def _execute_cypher( - self, query: str, kwargs: Optional[Dict[str, Any]] = None + self, query: str, kwargs: Optional[Dict[str, Any]] = None, + project_root: str = "", ) -> dict: """Cypher-like запрос к PropertyGraph.""" - from src.core.graph import PropertyGraph from src.core.search.cypher_engine import CypherExecutor limit = (kwargs or {}).get("limit", 50) if not query: return {"status": "error", "action": "cypher", "message": "query is required"} - _kwargs = kwargs or {} - try: - pg = self._services.resolve(PropertyGraph) - except KeyError: - from src.core.artifact_paths import get_graph_db_path - - indexer = self.resolve_indexer() - project_path = indexer.project_path - db_path = get_graph_db_path(project_path) - pg = PropertyGraph(db_path) - + pg = self._resolve_pg(project_root) executor = CypherExecutor(pg) q = query.strip() @@ -305,22 +317,26 @@ async def _execute_cypher( } async def _execute_related( - self, file_path: str, kwargs: Optional[Dict[str, Any]] = None + self, file_path: str, kwargs: Optional[Dict[str, Any]] = None, + project_root: str = "", ) -> dict: """Связанные файлы через CommitMemory + RelationExtractor.""" from src.core.commit_memory import CommitMemory from src.core.relation_extractor import RelationExtractor _kwargs = kwargs or {} - project_root = _kwargs.get("project_root", "") + project_root = project_root or _kwargs.get("project_root", "") max_depth = _kwargs.get("max_depth", 1) - target_path = Path(project_root).resolve() + if project_root and project_root.strip(): + target_path = Path(project_root).resolve() + else: + target_path = Path(self.resolve_indexer().project_path) if not target_path.exists(): return { "status": "error", "action": "related", - "message": f"Path does not exist: {project_root}", + "message": f"Path does not exist: {target_path}", } memory = CommitMemory(target_path) @@ -358,7 +374,8 @@ async def _execute_related( } async def _execute_path( - self, target: str = "", kwargs: Optional[Dict[str, Any]] = None + self, target: str = "", kwargs: Optional[Dict[str, Any]] = None, + project_root: str = "", ) -> dict: """Path Query: кратчайший путь между двумя символами (BFS PropertyGraph). @@ -388,16 +405,7 @@ async def _execute_path( "message": f"direction должен быть outgoing|incoming|both, got {direction!r}", } - from src.core.graph import PropertyGraph - - try: - pg = self._services.resolve(PropertyGraph) - except KeyError: - from src.core.artifact_paths import get_graph_db_path - - indexer = self.resolve_indexer() - project_path = indexer.project_path - pg = PropertyGraph(get_graph_db_path(project_path)) + pg = self._resolve_pg(project_root) def _resolve(qname_or_name: str): node = pg.get_node(qname_or_name) @@ -442,10 +450,10 @@ def _resolve(qname_or_name: str): } async def _execute_flow( - self, name: str, kwargs: Optional[Dict[str, Any]] = None + self, name: str, kwargs: Optional[Dict[str, Any]] = None, + project_root: str = "", ) -> dict: """Трассировка потока данных переменной (ASSIGNED_FROM).""" - from src.core.graph import PropertyGraph from src.core.search.graph_adapter import SymbolIndexAdapter _kwargs = kwargs or {} @@ -456,19 +464,8 @@ async def _execute_flow( if not name: return {"status": "error", "action": "flow", "message": "name is required"} - try: - pg = self._services.resolve(PropertyGraph) - adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) - except KeyError: - indexer = self.resolve_indexer() - pg = getattr(indexer, "_graph", None) or getattr(indexer, "property_graph", None) - if not pg: - return { - "status": "error", - "action": "flow", - "message": "PropertyGraph not available. Run reindex first.", - } - adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) + pg = self._resolve_pg(project_root) + adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) variables = adapter.find_variables(name=name, scope_id=scope_id, limit=20) if not variables: @@ -543,7 +540,7 @@ async def _execute_flow( }, } - async def _execute_arch_drift(self, file_path: str = "") -> dict: + async def _execute_arch_drift(self, file_path: str = "", project_root: str = "") -> dict: """Architecture Drift Detector: ищет структурные аномалии импортов. Анализирует PropertyGraph на паттерны, которые указывают @@ -562,37 +559,8 @@ async def _execute_arch_drift(self, file_path: str = "") -> dict: import sqlite3 from pathlib import Path - # Находим PropertyGraph (через indexer или напрямую) - db_path = None - try: - indexer = self.resolve_indexer() - pg = getattr(indexer, "_graph", None) or getattr(indexer, "property_graph", None) - if pg: - db_path = pg._db_path if hasattr(pg, '_db_path') else getattr(pg, 'path', None) - except Exception: - pass - - # Fallback: прямой путь к PropertyGraph (вне проекта, Задача 4/5) - if not db_path: - from pathlib import Path - - from src.core.artifact_paths import get_graph_db_path - try: - registry = self._services.resolve(ProjectIndexerRegistry) - roots = registry.active_project_paths() if hasattr(registry, 'active_project_paths') else [] - for r in roots: - candidate = get_graph_db_path(Path(r)) - if candidate.exists(): - db_path = str(candidate) - break - except Exception: - pass - if not db_path: - from src.core.artifact_paths import get_graph_db_path - candidate = get_graph_db_path(Path("D:/Project/MSCodeBase")) - if candidate.exists(): - db_path = str(candidate) - + pg = self._resolve_pg(project_root) + db_path = getattr(pg, "_db_path", None) or getattr(pg, "path", None) if not db_path or not Path(str(db_path)).exists(): return { "status": "error", @@ -678,7 +646,8 @@ async def _execute_arch_drift(self, file_path: str = "") -> dict: # ── Claim Verifier ───────────────────────────────────── async def _execute_verify( - self, claim: str, kwargs: Optional[Dict[str, Any]] = None + self, claim: str, kwargs: Optional[Dict[str, Any]] = None, + project_root: str = "", ) -> dict: """Проверяет утверждение AI-агента против SymbolIndex + PropertyGraph. @@ -715,36 +684,9 @@ async def _execute_verify( if predicate not in SUPPORTED: return {"status": "error", "message": f"Unsupported predicate '{predicate}'. Supported: {sorted(SUPPORTED)}"} - # Get PropertyGraph - pg = None - db_path = None - try: - indexer = self.resolve_indexer() - pg = getattr(indexer, "_graph", None) or getattr(indexer, "property_graph", None) - if pg: - db_path = pg._db_path if hasattr(pg, '_db_path') else getattr(pg, 'path', None) - except Exception: - pass - if not db_path: - from pathlib import Path - - from src.core.artifact_paths import get_graph_db_path - try: - registry = self._services.resolve(ProjectIndexerRegistry) - roots = registry.active_project_paths() if hasattr(registry, 'active_project_paths') else [] - for r in roots: - candidate = get_graph_db_path(Path(r)) - if candidate.exists(): - db_path = str(candidate) - break - except Exception: - pass - if not db_path: - from src.core.artifact_paths import get_graph_db_path - candidate = get_graph_db_path(Path("D:/Project/MSCodeBase")) - if candidate.exists(): - db_path = str(candidate) - + # Get PropertyGraph (explicit override ИЛИ активный проект) + pg = self._resolve_pg(project_root) + db_path = getattr(pg, "_db_path", None) or getattr(pg, "path", None) if not db_path or not Path(str(db_path)).exists(): return _unverifiable("PropertyGraph not available", predicate) diff --git a/src/mcp/tools/search_tools.py b/src/mcp/tools/search_tools.py index 5589d75d..004714c4 100644 --- a/src/mcp/tools/search_tools.py +++ b/src/mcp/tools/search_tools.py @@ -84,6 +84,16 @@ def _get_stale_warning(searcher) -> str: 3. Показываем баннер один раз ("may be stale") """ try: + # Reindex fast-fail (инцидент 2026-08-25): во время переиндексации + # таблица пересоздаётся — sync-чтение LanceDB на loop-потоке блокирует + # event loop (или падает 'Not found'). Баннер не обязателен во время + # переиндексации — молчим мгновенно. + if callable(getattr(searcher, "_reindex_fast_fail", None)): + try: + if searcher._reindex_fast_fail() is True: + return '' + except Exception: # noqa: BLE001 — баннер не роняет поиск + pass indexer = getattr(searcher, 'indexer', None) if indexer is None or not hasattr(indexer, 'table') or indexer.table is None: return '' @@ -216,7 +226,7 @@ async def execute( stale_banner = _get_stale_warning(searcher) # === Project header === - project_header = self._project_header() + project_header = self._project_header(explicit_project_root=_pr or None) if filter_layer: project_header += _("\n🔬 Layer filter: {layer}", layer=filter_layer) if effective_limit != limit: @@ -236,7 +246,7 @@ async def execute( # на future.result(30) и могла отравить общий _sync_executor # застрявшими asyncio.run-тасками → все последующие quality-поиски # падали в 30s-таймаут, а веб-инструменты висли. - _searcher = self.resolve_searcher() + _searcher = self.resolve_searcher(explicit_project_root=_pr or None) raw = await asyncio.to_thread( _searcher.search_with_mode, query, @@ -313,7 +323,7 @@ def _safe_float(v, default=0.0): + project_header + "\n" + await asyncio.to_thread( - self.resolve_searcher().deep_search, query, limit=effective_limit + self.resolve_searcher(explicit_project_root=_pr or None).deep_search, query, limit=effective_limit ) ) @@ -323,7 +333,7 @@ def _safe_float(v, default=0.0): stale_banner + project_header + "\n" - + self.resolve_searcher().context_search(query, limit=effective_limit) + + self.resolve_searcher(explicit_project_root=_pr or None).context_search(query, limit=effective_limit) ) elif mode == "ask": @@ -336,7 +346,7 @@ def _safe_float(v, default=0.0): "Переключение на mode=quality." ) raw = await asyncio.to_thread( - self.resolve_searcher().search_with_mode, + self.resolve_searcher(explicit_project_root=_pr or None).search_with_mode, query, mode="quality", limit=effective_limit, @@ -362,7 +372,7 @@ def _safe_float(v, default=0.0): stale_banner + project_header + "\n" - + await self.resolve_searcher().ask_async(query, limit=effective_limit) + + await self.resolve_searcher(explicit_project_root=_pr or None).ask_async(query, limit=effective_limit) ) else: @@ -371,14 +381,14 @@ def _safe_float(v, default=0.0): since = kwargs.get("since") if kwargs else None before = kwargs.get("before") if kwargs else None if _is_complex_query(query): - result_str = stale_banner + project_header + "\n" + await self._agentic_search(query) + result_str = stale_banner + project_header + "\n" + await self._agentic_search(query, _pr) else: result_str = ( stale_banner + project_header + "\n" + await asyncio.to_thread( - self.resolve_searcher().search, + self.resolve_searcher(explicit_project_root=_pr or None).search, query, limit=effective_limit, since=since, @@ -442,15 +452,16 @@ def _safe_float(v, default=0.0): return result_str - async def _agentic_search(self, query: str) -> str: + async def _agentic_search(self, query: str, project_root: str = "") -> str: """Agentic Code Search с декомпозицией и связями. Multi-window (INC-6BCB-v2): self.searcher / self.symbol_index НЕ существуют в базовом MCPTool (Indexer per-project → Searcher per-project). Резолвим через resolve_searcher() / resolve_symbol_index(). + project_root: явный путь (R3TF, 2026-08-26) — искать в целевом проекте. """ - searcher = self.resolve_searcher() - symbol_index = self.resolve_symbol_index() + searcher = self.resolve_searcher(explicit_project_root=project_root or None) + symbol_index = self.resolve_symbol_index(explicit_project_root=project_root or None) try: results, metadata = searcher.agentic_code_search( query, @@ -512,11 +523,11 @@ def _format_results(result: Any, mode: str, project_header: str = "") -> str: if not ln.startswith("// File:") and not ln.startswith("// File :") ] clean_text = "\n".join(clean_lines).strip() - # BS-3: координаты в БД 0-based (tree-sitter), пользователю нужны - # 1-based номера строк (аудит Bot_snow: «line 0/2 вместо L27-71»). + # Координаты хранятся 1-based на месте захвата (parser.py), + # поэтому коррекция не нужна (BS-3 закрыт в capture-сайте). sl = meta.get("start_line") if isinstance(sl, int) and sl >= 0: - start_line_display = sl + 1 + start_line_display = sl else: # Фолбэк для старых данных/других источников без start_line start_line_display = meta.get("chunk_index", "") diff --git a/src/utils/ui_formatter.py b/src/utils/ui_formatter.py index af2625e6..b46b876a 100644 --- a/src/utils/ui_formatter.py +++ b/src/utils/ui_formatter.py @@ -238,11 +238,20 @@ def format_runtime_status(data: Dict[str, Any]) -> str: chunks = tel.get("total_chunks", 0) files = tel.get("unique_files", 0) symbols = tel.get("symbol_index_count", tel.get("total_files", 0)) + # Reindex (Вариант А, 2026-08-25): во время переиндексации total_chunks + # = 0 — если молчать, агент решит «индекс пуст» и запустит 2-й reindex. + # Показываем честный статус + прогресс/ETA (если jobManager дал). + reindexing = tel.get("reindex_in_progress") is True idx_led = "🟢" if chunks > 1000 else ("🟡" if chunks > 0 else "⚪") + if reindexing: + idx_led = "🟢" # процесс идёт — это здоровое состояние, не «пусто» # ─── Общий статус ───────────────────────────────── all_green = provider != "unknown" and chunks > 0 - health_led = "🟢" if all_green else ("🟡" if chunks > 0 else "⚪") + # Reindex: индексация идёт — это не «красный», а «ожидание» (🟡). + health_led = ( + "🟢" if (all_green or reindexing) else ("🟡" if chunks > 0 else "⚪") + ) # ─── Reranker ────────────────────────────────────── _reranker_port = data.get("resource_usage", {}).get("llama_rerank_ram", None) @@ -253,6 +262,23 @@ def format_runtime_status(data: Dict[str, Any]) -> str: _reranker_led = "🔴" _reranker_info = "offline" + _reindex_pct = tel.get("reindex_progress_pct") + _reindex_eta = tel.get("reindex_eta_sec") + if reindexing: + # Честный статус вместо «0 chunks»: агент ждёт, а не чинит. + _idx_line = ( + " {il} 🔄 Reindex in progress" + + (f" ({_reindex_pct}%)" if _reindex_pct is not None else "") + + ( + f" (ETA ~{_reindex_eta // 60}m {_reindex_eta % 60}s)" + if _reindex_eta is not None + else "" + ) + + "\n" + ) + else: + _idx_line = " {il} {chunks} chunks | {files} files | {symbols} symbols\n" + result = _( "{hl} **MSCodeBase** — {proj}\n" "━━━━━━━━━━━━━━━━━━━━━━━━━\n" @@ -263,7 +289,7 @@ def format_runtime_status(data: Dict[str, Any]) -> str: "⚡ **Reranker**\n" " {rrl} {rrn}\n" "📦 **Index**\n" - " {il} {chunks} chunks | {files} files | {symbols} symbols\n" + " " + _idx_line + "⚙️ **System**\n" " PID: {pid}\n", hl=health_led, diff --git a/telemetry/tool_metrics.json b/telemetry/tool_metrics.json index e218d067..8ae90938 100644 --- a/telemetry/tool_metrics.json +++ b/telemetry/tool_metrics.json @@ -1,122 +1,77 @@ { - "search_code": { - "calls": 1, - "errors": 1, - "total_ms": 6879, - "min_ms": 6879, - "max_ms": 6879, - "last_call": "20:51:55", - "route": {}, - "avg_confidence": 0.0, - "avg_results": 0.0, - "last_detail": "", - "idle_ms": 14208, - "idle_calls": 1, - "repeat_count": 0 - }, - "get_symbol_info": { - "calls": 1, - "errors": 1, - "total_ms": -994, - "min_ms": -994, - "max_ms": 0, - "last_call": "20:52:00", - "route": {}, - "avg_confidence": 0.0, - "avg_results": 0.0, - "last_detail": "", - "idle_ms": 5014, - "idle_calls": 1, - "repeat_count": 0 - }, - "impact_analysis": { - "calls": 1, - "errors": 1, - "total_ms": 6864, - "min_ms": 6864, - "max_ms": 6864, - "last_call": "20:52:07", - "route": {}, - "avg_confidence": 0.0, - "avg_results": 0.0, - "last_detail": "", - "idle_ms": 6865, - "idle_calls": 1, - "repeat_count": 0 - }, - "get_index_status": { - "calls": 1, + "lsp_get_diagnostics": { + "calls": 2, "errors": 0, - "total_ms": 521, - "min_ms": 521, - "max_ms": 521, - "last_call": "20:52:07", + "total_ms": 2710, + "min_ms": 708, + "max_ms": 2002, + "last_call": "23:30:53", "route": {}, "avg_confidence": 0.0, "avg_results": 0.0, "last_detail": "", - "idle_ms": 521, - "idle_calls": 1, + "idle_ms": 140229, + "idle_calls": 2, "repeat_count": 0 }, - "notify_change": { - "calls": 1, - "errors": 1, - "total_ms": 13, - "min_ms": 13, - "max_ms": 13, - "last_call": "20:52:07", + "lsp_document_symbols": { + "calls": 2, + "errors": 0, + "total_ms": 1528, + "min_ms": 525, + "max_ms": 1003, + "last_call": "23:28:35", "route": {}, "avg_confidence": 0.0, "avg_results": 0.0, "last_detail": "", - "idle_ms": 13, - "idle_calls": 1, - "repeat_count": 0 + "idle_ms": 19960, + "idle_calls": 2, + "repeat_count": 1 }, - "get_health_report": { + "lsp_find_definition": { "calls": 1, - "errors": 1, - "total_ms": 529, - "min_ms": 529, - "max_ms": 529, - "last_call": "20:52:08", + "errors": 0, + "total_ms": 524, + "min_ms": 524, + "max_ms": 524, + "last_call": "23:28:43", "route": {}, "avg_confidence": 0.0, "avg_results": 0.0, "last_detail": "", - "idle_ms": 529, + "idle_ms": 7149, "idle_calls": 1, "repeat_count": 0 }, - "rename_symbol": { + "lsp_find_references": { "calls": 1, "errors": 1, - "total_ms": 6862, - "min_ms": 6862, - "max_ms": 6862, - "last_call": "20:52:15", + "total_ms": 15004, + "min_ms": 15004, + "max_ms": 15004, + "last_call": "23:29:04", "route": {}, "avg_confidence": 0.0, "avg_results": 0.0, "last_detail": "", - "idle_ms": 6862, + "idle_ms": 21752, "idle_calls": 1, "repeat_count": 0 }, - "replace_symbol": { - "calls": 1, - "errors": 1, - "total_ms": 6864, - "min_ms": 6864, - "max_ms": 6864, - "last_call": "20:52:22", + "lsp_get_type_info": { + "calls": 2, + "errors": 2, + "total_ms": 30010, + "min_ms": 15005, + "max_ms": 15005, + "last_call": "23:29:48", "route": {}, "avg_confidence": 0.0, "avg_results": 0.0, "last_detail": "", - "idle_ms": 6864, - "idle_calls": 1, - "repeat_count": 0 + "idle_ms": 43177, + "idle_calls": 2, + "repeat_count": 1 } } \ No newline at end of file diff --git a/tests/test_database_lock_selfhealing.py b/tests/test_database_lock_selfhealing.py index 8d1a61e4..10faf170 100644 --- a/tests/test_database_lock_selfhealing.py +++ b/tests/test_database_lock_selfhealing.py @@ -1,19 +1,21 @@ -"""Regression-тесты WS9: self-healing PID-lock (вариант C). - -Покрывают (AC-1..AC-8, KNOWN_ISSUES#2026-08-08-multiwindow-pidlock): -1. healthy Zed/Python/LSP chain → wait ≤ wait_timeout → LockBusyError (holder НЕ убит); -2. Zed alive + child dead (holder мёртв) → stale → steal; -3. orphan root (holder жив, корень цепочки мёртв) → terminate + steal; -4. PID reuse (create_time > started + tolerance) → stale → steal; -5. lock race (N процессов, ровно один владелец); -6. termination race (lock пересоздан другим процессом во время steal) → fail-closed, +"""Regression-тесты R3TF: fail-closed PID-lock (вариант A+, 2026-08-26). + +Покрывают (AK-1..AK-8, REDTEAM_lock_attacks.md): +1. живой PID (в т.ч. с мёртвым корнем цепочки — venvwlauncher) → wait → + LockBusyError, holder НЕ убит (инцидент 2026-08-26: PID 20052 killed by 12524); +2. мёртвый PID (Zed alive + child dead) → stale → steal; +3. PID reuse (create_time > started + tolerance) → stale → steal; +4. hostname чужой (v2) → AMBIGUOUS → wait, не трогаем; +5. неизвестная версия формата → AMBIGUOUS → wait; +6. lock race (N процессов, ровно один владелец); +7. termination race (lock пересоздан другим процессом во время steal) → fail-closed, чужой lock не удаляется; -7. stale lock (мёртвый pid) → steal без ожидания; -8. concurrent acquisition (N потоков: 1 winner, остальные LockBusyError). +8. stale lock (мёртвый pid) → steal без ожидания. + +TerminateProcess удалён полностью (R3TF): DatabaseLock никогда не убивает +живые процессы — «break stale lock only on proof of death» (PostgreSQL/Qt/filelock). Используют инжектируемый ProcessInspector (фейковые цепочки без реальных -OS-процессов — работает на Linux CI). Windows-only live-тест TerminateProcess — -skipif. """ import json @@ -101,25 +103,58 @@ def test_healthy_chain_zed_alive(self, tmp_path): lock = DatabaseLock(tmp_path / ".write_lock", holder_inspector=insp) assert lock.classify_holder(12345, time.time() - 5) is LockHolderState.HEALTHY - def test_orphan_root_dead(self, tmp_path): + def test_live_pid_with_dead_root_is_healthy(self, tmp_path): + """R3TF: живой PID с мёртвым корнем цепочки → HEALTHY (wait), + НЕ ORPHAN и НЕ kill. Это фикс инцидента 2026-08-26 (PID 20052: + venvwlauncher-цепочка обрывается на мёртвом предке). + """ chain = [(12345, "python.exe", True), (77777, "?", False)] # корень мёртв insp = FakeInspector(alive=True, create_time=time.time() - 5, chain=chain) lock = DatabaseLock(tmp_path / ".write_lock", holder_inspector=insp) - assert lock.classify_holder(12345, time.time() - 5) is LockHolderState.ORPHAN + assert lock.classify_holder(12345, time.time() - 5) is LockHolderState.HEALTHY - def test_ambiguous_no_chain(self, tmp_path): + def test_live_pid_no_chain_is_healthy(self, tmp_path): + """R3TF: chain=None больше не = AMBIGUOUS: живой PID с валидным + create_time — HELD (fail-closed wait), независимо от цепочки.""" insp = FakeInspector(alive=True, create_time=time.time() - 5, chain=None) lock = DatabaseLock(tmp_path / ".write_lock", holder_inspector=insp) - assert lock.classify_holder(12345, time.time() - 5) is LockHolderState.AMBIGUOUS + assert lock.classify_holder(12345, time.time() - 5) is LockHolderState.HEALTHY - def test_ambiguous_live_root_no_zed(self, tmp_path): + def test_live_pid_live_root_no_zed_is_healthy(self, tmp_path): + """R3TF: живой, но не Zed-предок → HEALTHY (не AMBIGUOUS).""" chain = [ (12345, "python.exe", True), (22222, "python.exe", True), # живой, но не Zed ] insp = FakeInspector(alive=True, create_time=time.time() - 5, chain=chain) lock = DatabaseLock(tmp_path / ".write_lock", holder_inspector=insp) - assert lock.classify_holder(12345, time.time() - 5) is LockHolderState.AMBIGUOUS + assert lock.classify_holder(12345, time.time() - 5) is LockHolderState.HEALTHY + + def test_foreign_hostname_is_ambiguous(self, tmp_path): + """R3TF (атака 6): lock с чужого host → AMBIGUOUS (PID непроверяем).""" + insp = FakeInspector(alive=True, create_time=time.time() - 5) + lock = DatabaseLock(tmp_path / ".write_lock", holder_inspector=insp) + holder_data = {"pid": 12345, "started": time.time() - 5, + "hostname": "other-machine", "v": 2} + assert lock.classify_holder(12345, time.time() - 5, holder_data) is LockHolderState.AMBIGUOUS + + def test_unsupported_version_is_ambiguous(self, tmp_path): + """R3TF (атака 7): неизвестная версия формата → AMBIGUOUS (fail-closed).""" + import socket + insp = FakeInspector(alive=True, create_time=time.time() - 5) + lock = DatabaseLock(tmp_path / ".write_lock", holder_inspector=insp) + holder_data = {"pid": 12345, "started": time.time() - 5, + "hostname": socket.gethostname(), "v": 99} + assert lock.classify_holder(12345, time.time() - 5, holder_data) is LockHolderState.AMBIGUOUS + + def test_own_host_v2_is_healthy(self, tmp_path): + """Нормальный v2-lock (наш host, валидный create_time) → HEALTHY.""" + import socket + insp = FakeInspector(alive=True, create_time=time.time() - 5) + lock = DatabaseLock(tmp_path / ".write_lock", holder_inspector=insp) + holder_data = {"pid": 12345, "started": time.time() - 5, + "hostname": socket.gethostname(), "v": 2} + assert lock.classify_holder(12345, time.time() - 5, holder_data) is LockHolderState.HEALTHY # ─── Кейс 1: healthy chain → wait → LockBusyError, holder НЕ убит ─ @@ -178,42 +213,51 @@ def test_dead_holder_stolen_even_with_live_zed_upstream(self, tmp_path): lock.release() -# ─── Кейс 3: orphan root → terminate → steal ─────────────────── - -class TestOrphanHolder: - def test_orphan_terminated_and_stolen(self, tmp_path, monkeypatch): - chain = [(12345, "python.exe", True), (77777, "?", False)] - insp = FakeInspector(alive=True, create_time=time.time() - 5, chain=chain) - lock_path = tmp_path / ".write_lock" - _write_lock(lock_path, 12345, time.time() - 5) +# ─── Кейс 3: живой holder (мёртвый корень цепочки) → wait → LockBusyError, НЕ kill ─ - lock = DatabaseLock(lock_path, wait_timeout=0.3, holder_inspector=insp) - terminated = [] - monkeypatch.setattr(lock, "_terminate_holder", - lambda pid: terminated.append(pid) or True) - lock.acquire() - try: - assert terminated == [12345], "зомби должен быть терминейтнут" - assert lock.is_held() - data = json.loads(lock_path.read_text(encoding="utf-8")) - assert data["pid"] == os.getpid() - finally: - lock.release() +class TestLiveHolderWithDeadRoot: + """R3TF (инцидент 2026-08-26): живой процесс с мёртвым предком больше + НЕ терминейтится — TerminateProcess удалён. Venvwlauncher-цепочки живых + MCP дают ложный «orphan»-вид, но holder HELD → LockBusyError (fail-closed). + """ - def test_orphan_terminate_fails_soft(self, tmp_path, monkeypatch): + def test_live_holder_with_dead_root_waits_and_soft_fails(self, tmp_path): chain = [(12345, "python.exe", True), (77777, "?", False)] insp = FakeInspector(alive=True, create_time=time.time() - 5, chain=chain) lock_path = tmp_path / ".write_lock" _write_lock(lock_path, 12345, time.time() - 5) - lock = DatabaseLock(lock_path, wait_timeout=0.3, holder_inspector=insp) - monkeypatch.setattr(lock, "_terminate_holder", lambda pid: False) - with pytest.raises(LockBusyError, match="не удалось завершить"): + lock = DatabaseLock(lock_path, wait_timeout=0.3, poll_interval=0.05, + holder_inspector=insp) + with pytest.raises(LockBusyError, match="still held by alive pid"): lock.acquire() - # fail-closed: чужой lock не тронут + # holder НЕ убит и lock не тронут (TerminateProcess отсутствует) assert lock_path.exists() + assert not lock.is_held() data = json.loads(lock_path.read_text(encoding="utf-8")) assert data["pid"] == 12345 + # terminate-путь не вызывался: нет ни одного _terminate_holder вызова + assert not hasattr(lock, "_terminate_holder"), "TerminateProcess путь удалён" + + def test_fake_lock_foreign_live_pid_not_killed(self, tmp_path): + """R3TF (атака 1): поддельный lock с чужим ЖИВЫМ PID (например + explorer.exe) → HELD (wait → LockBusyError), НЕ TerminateProcess. + """ + import socket + insp = FakeInspector(alive=True, create_time=time.time() - 5) + lock_path = tmp_path / ".write_lock" + _write_lock(lock_path, 12345, time.time() - 5) + # v2 формат, чужой hostname отсутствует → наш host, но PID чужой живой + lock_path.write_text(json.dumps({ + "v": 2, "pid": 12345, "started": time.time() - 5, + "role": "worker", "hostname": socket.gethostname(), + }), encoding="utf-8") + + lock = DatabaseLock(lock_path, wait_timeout=0.3, poll_interval=0.05, + holder_inspector=insp) + with pytest.raises(LockBusyError): + lock.acquire() + assert lock_path.exists() # чужой lock не удалён, никто не убит # ─── Кейс 4: PID reuse → steal ───────────────────────────────── @@ -271,23 +315,27 @@ def _try(): lock.release() -# ─── Кейс 6: termination race ────────────────────────────────── +# ─── Кейс 6: steal-race (DEAD) ────────────────────────────── -class TestTerminationRace: +class TestStealRace: def test_fresh_owner_not_stolen_during_steal(self, tmp_path, monkeypatch): """Lock пересоздан другим процессом до unlink — чужой свежий lock - не удаляется (fail-closed), несмотря на подтверждённый ORPHAN.""" - chain = [(999_999_998, "python.exe", True), (77777, "?", False)] - insp = FakeInspector(alive=True, create_time=time.time() - 5, chain=chain) + не удаляется (fail-closed). Путь steal теперь ДОСТИЖИМ только для + мёртвого holder'а (DEAD): TerminateProcess удалён, живой holder + → HELD, steal не начинается. + """ + insp = FakeInspector(alive=False) # мёртвый holder → DEAD → steal lock = DatabaseLock(tmp_path / ".write_lock", wait_timeout=0.2, holder_inspector=insp) - monkeypatch.setattr(lock, "_terminate_holder", lambda pid: True) + # R3TF: _terminate_holder больше не существует в классе — + # assert отсутствия kill-пути. + assert not hasattr(lock, "_terminate_holder") started_old = time.time() - 5 _write_lock(tmp_path / ".write_lock", 999_999_998, started_old) - # Первый вызов _read_holder — ORPHAN-holder; второй (после terminate, - # перед unlink) — lock уже пересоздан ДРУГИМ процессом. + # Первый вызов _read_holder — мёртвый holder (DEAD); второй (после + # классификации, перед unlink) — lock уже пересоздан ДРУГИМ процессом. calls = {"n": 0} def _fake_read(): @@ -298,16 +346,13 @@ def _fake_read(): monkeypatch.setattr(lock, "_read_holder", _fake_read) - try: - with pytest.raises(LockBusyError, match="пересоздан другим процессом"): - lock.acquire() - # чужой lock не удалён (файл на диске не тронут — данные прежние) - assert (tmp_path / ".write_lock").exists() - data = json.loads((tmp_path / ".write_lock").read_text(encoding="utf-8")) - assert data["pid"] == 999_999_998 - assert not lock.is_held() - finally: - pass + with pytest.raises(LockBusyError, match="пересоздан другим процессом"): + lock.acquire() + # чужой lock не удалён (файл на диске не тронут — данные прежние) + assert (tmp_path / ".write_lock").exists() + data = json.loads((tmp_path / ".write_lock").read_text(encoding="utf-8")) + assert data["pid"] == 999_999_998 + assert not lock.is_held() # ─── Кейс 7: stale lock ──────────────────────────────────────── @@ -334,16 +379,17 @@ def test_dead_pid_stolen_immediately(self, tmp_path): lock.release() -# ─── Live-тест: реальная TerminateProcess (Windows) ──────────── +# ─── Live-тест: реальный живой процесс НЕ убивается (R3TF) ──── -class TestLiveTerminateWindows: - @pytest.mark.skipif(sys.platform != "win32", reason="TerminateProcess — Windows-only") - def test_real_orphan_process_terminated_and_stolen(self, tmp_path): - """Реальный дочерний процесс + реальный TerminateProcess. +class TestLiveProcessNotKilled: + """Регрессия инцидента 2026-08-26: живой процесс (даже с «мёртвым + предком»-видом) → HELD → LockBusyError, НИКОГДА TerminateProcess. + """ - Инжектор: реальный is_alive/create_time (WindowsProcessInspector), - но parent_chain фейковый (корень мёртв) → ORPHAN. - """ + @pytest.mark.skipif(sys.platform != "win32", reason="Процесс-тест — Windows-only") + def test_real_live_process_not_terminated(self, tmp_path): + """Реальный дочерний процесс держит lock — второй acquire НЕ убивает + его, а ждёт и падает с LockBusyError (сценарий PID 20052).""" proc = subprocess.Popen( [sys.executable, "-c", "import time; time.sleep(60)"], stdout=subprocess.DEVNULL, @@ -355,22 +401,17 @@ def test_real_orphan_process_terminated_and_stolen(self, tmp_path): # иначе create_time-guard отнесёт к PID-reuse). _write_lock(lock_path, proc.pid, time.time()) - class LiveOrphanInspector(WindowsProcessInspector): - def parent_chain(self, pid, max_levels=8): - return [(pid, "python.exe", True), (77777, "?", False)] - - lock = DatabaseLock(lock_path, wait_timeout=0.3, - holder_inspector=LiveOrphanInspector()) + lock = DatabaseLock(lock_path, wait_timeout=0.3, poll_interval=0.05) t0 = time.monotonic() - lock.acquire() + with pytest.raises(LockBusyError, match="still held by alive pid"): + lock.acquire() elapsed = time.monotonic() - t0 - try: - assert lock.is_held() - assert elapsed < 3.0, f"terminate+steal занял {elapsed:.2f}s" - finally: - lock.release() - # дочерний процесс реально убит - proc.wait(timeout=10) + # HELD-путь ждёт ≤ wait_timeout (0.3s), потом LockBusyError + assert 0.2 <= elapsed < 1.5, f"HELD-wait занял {elapsed:.2f}s" + assert not lock.is_held() + # ЖИВОЙ процесс НЕ убит (центральный инвариант R3TF!) + assert proc.poll() is None, "holder БЫЛ УБИТ — регрессия инцидента!" + assert lock_path.exists() # чужой lock не тронут finally: if proc.poll() is None: proc.kill() diff --git a/tests/test_graph_query_project_binding.py b/tests/test_graph_query_project_binding.py new file mode 100644 index 00000000..9e486c8e --- /dev/null +++ b/tests/test_graph_query_project_binding.py @@ -0,0 +1,84 @@ +"""DoD #3 — Multi-project isolation: graph_query биндится к explicit project_root. + +Проверяет, что graph_query.execute(thread=project_root) честно пробрасывает +project_root в resolve_indexer / resolve_symbol_index (и значит — к нужному +проекту, а не к активному MCP-проекту по умолчанию), и что _resolve_pg +предпочитает explicit-граф при его наличии. +""" +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from src.mcp.tools.graph_tools import GraphQueryTool + + +@pytest.mark.asyncio +async def test_graph_query_threads_explicit_project_root(): + tool = GraphQueryTool.__new__(GraphQueryTool) + captured = {} + + def fake_indexer(explicit_project_root=None, bypass_cache=False): + captured["indexer_pr"] = explicit_project_root + idx = MagicMock() + idx.project_path = Path("/active/default") + return idx + + def fake_si(explicit_project_root=None): + captured["si_pr"] = explicit_project_root + return MagicMock() + + tool.resolve_indexer = fake_indexer + tool.resolve_symbol_index = fake_si + + def _run(project_root): + with patch("src.core.graph_rag.GraphRAGQueryEngine") as GE: + ge_inst = MagicMock() + ge_inst.query_impact.return_value = { + "risk_score": 0, "direct_impact": [], "tests_to_run": [], + } + GE.return_value = ge_inst + return GE + + # explicit project_root -> должен дойти до resolve_* как "/tmp/projB" + _run("/tmp/projB") + out = await tool._execute_query("impact", "foo", {}, project_root="/tmp/projB") + assert out["status"] == "ok" + assert captured["indexer_pr"] == "/tmp/projB" + assert captured["si_pr"] == "/tmp/projB" + + # без project_root -> explicit_project_root=None (активный проект сессии) + captured.clear() + _run(None) + await tool._execute_query("impact", "foo", {}) + assert captured["indexer_pr"] is None + assert captured["si_pr"] is None + + +@pytest.mark.asyncio +async def test_graph_query_resolve_pg_prefers_explicit_when_exists(tmp_path): + """_resolve_pg: explicit project_root с существующим графом -> тот граф, + DI PropertyGraph НЕ дёргается.""" + tool = GraphQueryTool.__new__(GraphQueryTool) + tool._services = MagicMock() + tool._services.resolve.side_effect = AssertionError( + "DI resolve не должен зваться при explicit-пути" + ) + + graph_db = tmp_path / "mscodebase_graph.db" + graph_db.write_text("x") + + import src.core.artifact_paths as ap + import src.core.graph as gr + + orig_ap = ap.get_graph_db_path + orig_gr = gr.PropertyGraph + ap.get_graph_db_path = lambda p: graph_db + sentinel = object() + gr.PropertyGraph = lambda path: sentinel + try: + pg = tool._resolve_pg(str(tmp_path)) + assert pg is sentinel + finally: + ap.get_graph_db_path = orig_ap + gr.PropertyGraph = orig_gr diff --git a/tests/test_graph_stage_e4.py b/tests/test_graph_stage_e4.py new file mode 100644 index 00000000..8fe76363 --- /dev/null +++ b/tests/test_graph_stage_e4.py @@ -0,0 +1,92 @@ +"""Регрессионный тест Symbol Graph Path (Вариант А, Step 2 / E4.1). + +Проверяет, что граф-стадия: +1. Возвращает детерминированные symbol-указатели (file:line) для идентификатор-запросов. +2. Возвращает [] для неизвестных символов и при отсутствии индекса (graceful degradation). +3. Short-circuit в hybrid_search_async отдаёт граф-результат БЕЗ вызова embedder + (доказывает отказ от тяжёлого векторного поиска ~3600ms). +""" +import asyncio +import pytest + +from src.core.graph import PropertyGraph +from src.core.search.engine import Searcher +from src.core.search.graph_adapter import SymbolIndexAdapter + + +class _FakeEmbedder: + """embed БРОСАЕТ — чтобы доказать, что граф-short-circuit не доходит до вектора.""" + + def embed(self, *a, **k): + raise AssertionError("embedder не должен вызываться при graph short-circuit") + + def embed_batch_async(self, *a, **k): + raise AssertionError("embedder не должен вызываться при graph short-circuit") + + +def _make_searcher(symbol_index): + class _FakeIndexer: + _symbol_index = symbol_index + + return Searcher(_FakeIndexer(), _FakeEmbedder()) + + +@pytest.fixture +def searcher_with_graph(tmp_path): + db = tmp_path / "graph.db" + pg = PropertyGraph(str(db)) + pg.add_node( + name="save_symbol_index", label="Function", qualified_name="save_symbol_index", + file_path="src/core/indexing/index_guard.py", + properties={"line": 353, "kind": "function"}, + ) + pg.add_node( + name="resolve_indexer", label="Function", qualified_name="resolve_indexer", + file_path="src/mcp/tools/base.py", + properties={"line": 245, "kind": "function"}, + ) + adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) + s = _make_searcher(adapter) + yield s + pg.close() + + +def test_graph_stage_returns_symbol_pointers(searcher_with_graph): + results = searcher_with_graph._graph_stage("save_symbol_index", limit=5) + assert len(results) >= 1 + r = results[0] + assert r["metadata"]["symbol"] == "save_symbol_index" + assert r["metadata"]["symbol_name"] == "save_symbol_index" + assert r["metadata"]["is_symbol_ref"] is True + assert r["metadata"]["graph_stage"] is True + assert "index_guard.py" in r["metadata"]["file"] + assert r["metadata"]["layer"] == "core" + # sentinel chunk_index — отрицательный (уникален vs реальных чанков) + assert r["metadata"]["chunk_index"] < 0 + assert "📍" in r["text"] + + +def test_graph_stage_unknown_empty(searcher_with_graph): + assert searcher_with_graph._graph_stage("nonexistent_xyz_123", limit=5) == [] + + +def test_graph_stage_no_index(): + s = _make_searcher(None) + assert s._graph_stage("anything", limit=5) == [] + + +def test_graph_stage_layer_filter(searcher_with_graph): + # layer="mcp" не совпадает с core → пусто (защита от смешивания слоёв) + assert searcher_with_graph._graph_stage("save_symbol_index", limit=5, layer="mcp") == [] + # layer=None или "core" → находит + assert len(searcher_with_graph._graph_stage("save_symbol_index", limit=5, layer="core")) >= 1 + + +def test_short_circuit_identifier_no_embedder(searcher_with_graph): + """Идентификатор-запрос → граф-short-circuit, embedder НЕ вызывается.""" + results = asyncio.run( + searcher_with_graph.hybrid_search_async("save_symbol_index", limit=5) + ) + assert len(results) >= 1 + assert results[0]["metadata"]["graph_stage"] is True + assert "index_guard.py" in results[0]["metadata"]["file"] diff --git a/tests/test_mech_resolver.py b/tests/test_mech_resolver.py new file mode 100644 index 00000000..98ba83bc --- /dev/null +++ b/tests/test_mech_resolver.py @@ -0,0 +1,84 @@ +"""E4.2 — unit tests for the mechanical concept→symbol resolver + Option V fact text. + +Pure (no service stack): resolver.py imports only pathlib. +Proves the two E4.1 verify_change misses are fixed at the resolution layer, and +that graph_fact_text gathers enough def+docstring text to cover required_facts. +""" +import sys +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "experiments" / "mech_orch")) +import resolver # noqa: E402 + +T9_PROMPT = "Я изменил engine.py — какой инструмент вызвать для обновления индекса и как?" +T29_PROMPT = "Изменил паттерны извлечения — что проверить?" + + +# ── concept resolution (Option A) ────────────────────────────────────────── + +def test_t9_concept_maps_to_notify_change(): + assert resolver.concept_symbol(T9_PROMPT, "verify_change") == "notify_change" + + +def test_t9_klass_gating_fails_open_for_other_klass(): + # same phrase, wrong klass -> fail-open (no wrong mapping) + assert resolver.concept_symbol(T9_PROMPT, "find_impact") == "" + + +def test_t29_concept_maps_to_extract_symbol_name(): + assert resolver.concept_symbol(T29_PROMPT, "verify_change") == "_extract_symbol_name" + + +def test_t29_works_in_any_word_order(): + assert resolver.concept_symbol("паттерн извлечения изменил", "verify_change") == "_extract_symbol_name" + + +def test_no_recipe_returns_empty(): + assert resolver.concept_symbol("Почему падает write_records при сбое add?", "find_bug_cause") == "" + + +def test_empty_prompt_fails_open(): + assert resolver.concept_symbol("", "verify_change") == "" + assert resolver.concept_symbol(None, "verify_change") == "" + + +def test_case_and_whitespace_insensitive(): + assert resolver.concept_symbol(" ОБНОВИТЬ ИНДЕКС после правки ", "verify_change") == "notify_change" + + +# ── Option V: graph rows carry facts ─────────────────────────────────────── + +def test_read_snippet_returns_window_around_line(tmp_path): + f = tmp_path / "mod.py" + f.write_text("line1\nline2\ndef notify_change(file_path: str, kwargs=None):\n \"\"\"Обновляет индекс файла.\"\"\"\nline5\n", encoding="utf-8") + snip = resolver.read_snippet(str(f), 3) + assert "notify_change(file_path" in snip + assert "Обновляет индекс файла" in snip # docstring captured + + +def test_read_snippet_absent_file_returns_empty(tmp_path): + assert resolver.read_snippet(str(tmp_path / "missing.py"), 3) == "" + + +def test_graph_fact_text_covers_required_facts(tmp_path): + # realistic notify_change def in server_tools.py-like snippet + mod = tmp_path / "server_tools.py" + mod.write_text( + "\n".join([ + "line1", "line2", + "@mcp.tool('notify_change')", + "async def notify_change(file_path: str, kwargs=None) -> str:", + " \"\"\"Обновляет индекс одного файла через LSP VFS или диск. P0:", + " замыкает workflow edit -> notify -> reindex.\"\"\"", + "line7", "line8", + ]), + encoding="utf-8", + ) + adapter = SimpleNamespace( + find_definitions=lambda sym: [SimpleNamespace(file_path=str(mod), line=4, symbol=sym)] + ) + blob = resolver.graph_fact_text(adapter, "notify_change") + assert "server_tools.py" in blob # F4 + assert "notify_change(file_path" in blob # F1 signature + F2 file_path + assert "индекс" in blob # F3 diff --git a/tests/test_reindex_responsive.py b/tests/test_reindex_responsive.py index 17738c4c..e53bc274 100644 --- a/tests/test_reindex_responsive.py +++ b/tests/test_reindex_responsive.py @@ -117,3 +117,454 @@ def test_search_fast_fail_during_reindex(): # Контроль: при is_reindexing=False guard выключен (обычный путь) idx.db_manager = SimpleNamespace(is_reindexing=lambda: False) assert searcher._reindex_fast_fail() is False + + +def test_get_status_fast_fail_during_reindex_does_not_block(): + """Инцидент 2026-08-25 (root cause): full reindex держит _table_write_lock + ~7.5 мин (begin_write), а get_status() на loop-потоке ждал этот lock → + заморожен ВСЕ MCP-вызовы. Теперь: reindex_check=True → get_status() + возвращает кэш мгновенно, не трогая lock/БД. + + Двухрукавность (правило Тома): + - Arm 1 (reindex_check=True): возврат быстрый (< 0.3с) при ЗАХВАЧЕННОМ lock. + - Arm 2 (reindex_check=None): тот же lock → блокируется на acquire + (>= 0.4с пока lock держит фоновый поток) — демонстрирует, что БЕЗ + guard был именно lock-wait, а guard реально умеет падать/спасать. + """ + import threading + import time + from pathlib import Path + + from src.core.indexing.index_status import IndexStatusReporter + + lock = threading.RLock() + lock.acquire() # держим lock, как begin_write() во время reindex + try: + reporter = IndexStatusReporter( + table=None, + project_path=Path("unused"), + file_guard=None, + watchdog_callback=None, + table_write_lock=lock, + reindex_check=lambda: True, + ) + reporter._cached_total_chunks = 42 + reporter._cached_unique_files = {"a.py", "b.py"} + + t0 = time.perf_counter() + status = reporter.get_status() + dt = time.perf_counter() - t0 + + # Fast-fail: не ждём lock, отдаём кэш + флаг reindex в статусе + assert dt < 0.3, f"get_status заблокировался на {dt*1000:.0f}ms" + assert status["total_chunks"] == 42 + assert status["unique_files"] == 2 + assert status["status"] == "reindexing" + assert status["reindex_in_progress"] is True + finally: + lock.release() + + # Arm 2 (контроль): без reindex_check тот же lock блокирует get_status + import threading as _th + import time as _time + + lock2 = _th.RLock() + lock2.acquire() + try: + reporter2 = IndexStatusReporter( + table=None, + project_path=Path("unused"), + file_guard=None, + watchdog_callback=None, + table_write_lock=lock2, + reindex_check=None, # старый путь — lock-wait + ) + t0 = _time.perf_counter() + # Воркер в ОТДЕЛЬНОМ потоке: main держит lock, чужой поток обязан + # ждать acquire — это и есть заморозка loop-потока в инциденте. + result: dict = {} + + def _worker(): + result["status"] = reporter2.get_status() + result["dt"] = _time.perf_counter() - t0 + + _th.Thread(target=_worker, daemon=True).start() + _time.sleep(0.2) + # Через 0.2с воркер всё ещё ждёт lock → блокировка подтверждена + assert "status" not in result, \ + "Без reindex_check get_status НЕ должен вернуться, пока lock занят" + lock2.release() # освобождаем → воркер проходит acquire и завершается + _time.sleep(0.3) # дать воркеру пройти get_status (lock уже свободен) + assert "status" in result, "Воркер не завершился после release" + assert result["dt"] >= 0.2, \ + "Контроль: lock-wait обязан был занять >=0.2с" + finally: + try: + lock2.release() + except RuntimeError: + pass + + +async def test_require_ready_project_truthful_during_reindex(monkeypatch): + """Вариант А (2026-08-25): require_ready_project при reindex-состоянии + отдаёт честную ошибку «reindex in progress», а НЕ «Index is empty → run + index_project_dir» — иначе агент запустит ВТОРОЙ reindex. + + Контроль: без reindex-флага при пустом индексе — прежний + IndexNotReadyError (поведение не изменилось). + """ + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + from src.core.error_handler import IndexNotReadyError, ToolError + from src.mcp.tools.base import MCPTool + + class _Verdict: + def __init__(self, ok=True): + self.ok = ok + self.reason = "ready" if ok else "project_not_ready" + self.state = "READY" + self.detail = "" + self.retry_after = 0.0 + + class _Tool(MCPTool): + async def execute(self, **kwargs): + return {} + + fake_services = MagicMock() + tool = _Tool(fake_services) + + # Mock coordinator: проект READY (can_execute не блокирует реindex-путь) + async def _fake_can_execute(self, target, timeout=5.0): + return _Verdict(ok=True) + + monkeypatch.setattr( + "src.core.runtime_coordinator.RuntimeCoordinator.can_execute", + _fake_can_execute, + ) + + # Fake indexer: is_reindexing() => get_status возвращает reindex-кэш + fake_indexer = SimpleNamespace( + project_path=SimpleNamespace(name="demo"), + get_status=lambda: { + "total_chunks": 0, + "unique_files": 0, + "status": "reindexing", + "reindex_in_progress": True, + "reindex_progress_pct": 62, + "reindex_eta_sec": 264, + }, + ) + with patch.object( + _Tool, "resolve_indexer", return_value=fake_indexer + ): + try: + await tool.require_ready_project() + raise AssertionError("require_ready_project должен был бросить ToolError") + except ToolError as e: + assert "reindexing" in str(e.message).lower() or "reindex" in str(e.message).lower(), \ + f"Ожидали reindex-сообщение, получили: {e.message}" + assert "index_project_dir" not in str(e.message), \ + "Нельзя советовать запустить индексацию, когда она уже идёт" + assert e.recoverable is True + + # Контроль (Arm 2): БЕЗ reindex-флага и пустой индекс — прежний путь + fake_indexer_empty = SimpleNamespace( + project_path=SimpleNamespace(name="demo"), + get_status=lambda: {"total_chunks": 0, "unique_files": 0, "status": "empty"}, + ) + with patch.object(_Tool, "resolve_indexer", return_value=fake_indexer_empty): + try: + await tool.require_ready_project() + raise AssertionError("Ожидали IndexNotReadyError для пустого индекса") + except IndexNotReadyError: + pass + + # Контроль (Arm 3): ToolsError пробрасывается КАК ЕСТЬ (status/recoverable), + # а не заворачивается в «Failed to check index status» — иначе агент + # теряет retry-семантику (live: search показывал двойную обёртку). + def _boom_side_effect(): + raise ValueError("unexpected") + + fake_indexer_error = SimpleNamespace( + project_path=SimpleNamespace(name="demo"), + get_status=_boom_side_effect, + ) + with patch.object(_Tool, "resolve_indexer", return_value=fake_indexer_error): + try: + await tool.require_ready_project() + raise AssertionError("Ожидали ToolError при неожиданной ошибке") + except ToolError as e: + assert "Failed to check index status" in str(e.message), \ + f"Не-реindex ошибка должна заворачиваться, получили: {e.message}" + + +async def test_intel_runtime_status_reports_reindex_progress(monkeypatch, tmp_path): + """Вариант А: intel_get_runtime_status пробрасывает reindex-флаг и прогресс + в index_telemetry → агент видит «reindexing 62%», а не «0 chunks».""" + from types import SimpleNamespace + + from src.core.intelligence.jobs import job_manager + from src.core.intelligence.layer import ProjectIntelligenceLayer + + project_path = tmp_path / "demo" + project_path.mkdir(parents=True, exist_ok=True) + + fake_indexer = SimpleNamespace( + project_path=project_path, + db_path=SimpleNamespace(), + get_status=lambda: { + "total_chunks": 0, + "unique_files": 0, + "status": "reindexing", + "reindex_in_progress": True, + }, + ) + layer = ProjectIntelligenceLayer( + project_path=project_path, + indexer=fake_indexer, + searcher=None, + symbol_index=None, + ) + + # Регистрируем активный job в job_manager (общий синглтон). + # Настоящий BackgroundJob (датакласс), а не SimpleNamespace: + # _enrich_job_response делает asdict(job). + from src.core.intelligence.jobs import BackgroundJob + + job_id = "test_reindex_job" + job_manager.jobs[job_id] = BackgroundJob( + job_id=job_id, + type="full_reindex", + status="running", + progress=0.62, + started_at=__import__("time").time() - 60, + ) + monkeypatch.setattr(layer, "_reindex_job_id", job_id) + monkeypatch.setattr( + layer, + "_resolve_active_indexer", + lambda: fake_indexer, + ) + + try: + status = await layer.intel_get_runtime_status() + tel = status["index_telemetry"] + assert tel["reindex_in_progress"] is True + assert tel["status"] == "reindexing", f"status={tel['status']}" + assert tel["reindex_progress_pct"] == 62, f"pct={tel['reindex_progress_pct']}" + assert tel["reindex_eta_sec"] is not None + finally: + job_manager.jobs.pop(job_id, None) + monkeypatch.setattr(layer, "_reindex_job_id", None) + + +async def test_intel_runtime_status_normal_state_unchanged(monkeypatch, tmp_path): + """Контроль: без reindex — status=active по total_chunks, флагов нет.""" + from types import SimpleNamespace + + from src.core.intelligence.layer import ProjectIntelligenceLayer + + project_path = tmp_path / "demo" + project_path.mkdir(parents=True, exist_ok=True) + + fake_indexer = SimpleNamespace( + project_path=project_path, + db_path=SimpleNamespace(), + get_status=lambda: { + "total_chunks": 100, + "unique_files": 10, + "status": "active", + }, + ) + layer = ProjectIntelligenceLayer( + project_path=project_path, + indexer=fake_indexer, + searcher=None, + symbol_index=None, + ) + monkeypatch.setattr(layer, "_resolve_active_indexer", lambda: fake_indexer) + + status = await layer.intel_get_runtime_status() + tel = status["index_telemetry"] + assert tel["reindex_in_progress"] is False + assert tel["status"] == "active" + assert tel["total_chunks"] == 100 + + +async def test_delayed_auto_index_sets_state_ready(monkeypatch, tmp_path): + """Косметический баг 2026-08-26: после успешной авто-индексации registry + обязан перейти в READY (get_indexer ставит INDEXING при пустом индексе). + Иначе паспорт вечно показывает «Project State: INDEXING».""" + from types import SimpleNamespace + + from src.core.indexing.project_indexer_registry import ProjectState + from src.mcp.server_factory import _delayed_auto_index + + project_path = tmp_path / "demo" + project_path.mkdir(parents=True, exist_ok=True) + + # Fake registry: перехватываем set_state READY + states: list = [] + + class _FakeRegistry: + def set_state(self, path, state): + states.append((str(path), state)) + + registry_stub = _FakeRegistry() + + # Fake services: resolve(ProjectIndexerRegistry) → registry_stub + from src.core.di_container import ProjectIndexerRegistry as PIRKey + + services = SimpleNamespace() + + def _resolve(key): + if key is PIRKey: + return registry_stub + raise KeyError(key) + + services.resolve = _resolve + + # Fake indexer: ПУСТОЙ индекс до индексации (get_status → 0), чтобы + # _delayed_auto_index не пропустил запуск; index_project вернёт 42 файла. + calls = [] + fake_indexer = SimpleNamespace( + project_path=project_path, + db_manager=SimpleNamespace( + set_reindexing=lambda: None, + clear_reindexing=lambda: None, + is_reindexing=lambda: False, + ), + index_project=lambda path, *a, **k: calls.append(path) or 42, + get_status=lambda: {"total_chunks": 0, "unique_files": 0}, + ) + + with monkeypatch.context() as m: + m.setattr( + "src.mcp.tools.base.resolve_indexer_for_request", + lambda services, explicit_project_root=None: fake_indexer, + raising=False, + ) + await _delayed_auto_index(services) + + assert calls, "index_project не вызывался — авто-индексация не запустилась" + assert states, "set_state не вызывался после авто-индекса" + assert states[-1][1] is ProjectState.READY, f"state={states[-1][1]}" + + +async def test_delayed_auto_index_skips_when_index_not_empty(monkeypatch, tmp_path): + """Контроль: при непустом индексе авто-индексация пропускается, + set_state не вызывается (государство уже READY из get_indexer).""" + from types import SimpleNamespace + + from src.mcp.server_factory import _delayed_auto_index + + project_path = tmp_path / "demo" + project_path.mkdir(parents=True, exist_ok=True) + + states: list = [] + + class _FakeRegistry: + def set_state(self, path, state): + states.append((str(path), state)) + + from src.core.di_container import ProjectIndexerRegistry as PIRKey + + services = SimpleNamespace() + services.resolve = lambda key: _FakeRegistry() if key is PIRKey else None + + called = {"index_project": False} + fake_indexer = SimpleNamespace( + project_path=project_path, + db_manager=SimpleNamespace( + set_reindexing=lambda: None, + clear_reindexing=lambda: None, + is_reindexing=lambda: False, + ), + index_project=lambda path, *a, **k: called.__setitem__("index_project", True), + get_status=lambda: {"total_chunks": 9000, "unique_files": 500}, + ) + + with monkeypatch.context() as m: + m.setattr( + "src.mcp.tools.base.resolve_indexer_for_request", + lambda services, explicit_project_root=None: fake_indexer, + raising=False, + ) + await _delayed_auto_index(services) + + assert called["index_project"] is False, "Индекс не пуст — индексация не должна запускаться" + assert not states, "set_state не должен вызываться при пропуске авто-индекса" + + +async def test_reindex_job_sets_state_ready(monkeypatch, tmp_path): + """_run_reindex_job: после успешного job → registry.set_state(READY). + Контролируем, что state machine закрывает цикл INDEXING→READY.""" + import asyncio + from types import SimpleNamespace + + from src.core.indexing.project_indexer_registry import ProjectState + from src.core.intelligence.layer import ProjectIntelligenceLayer + + project_path = tmp_path / "demo" + project_path.mkdir(parents=True, exist_ok=True) + + states: list = [] + + class _FakeRegistry: + def set_state(self, path, state): + states.append((str(path), state)) + + from src.core.di_container import ProjectIndexerRegistry as PIRKey + + fake_services = SimpleNamespace() + fake_services.resolve = lambda key: _FakeRegistry() if key is PIRKey else None + + fake_indexer = SimpleNamespace( + project_path=project_path, + db_manager=SimpleNamespace( + set_reindexing=lambda: None, + clear_reindexing=lambda: None, + is_reindexing=lambda: False, + ), + file_guard=None, + index_project=lambda path, cb=None: 10, + ) + + layer = ProjectIntelligenceLayer( + project_path=project_path, + indexer=fake_indexer, + searcher=None, + symbol_index=None, + services=fake_services, + ) + + # Авто-док после реиндекса — мокаем (не выводим на реальный AutoDocUpdater) + class _FakeUpdater: + def update_all(self, project_root: str) -> str: + return "ok" + + monkeypatch.setattr( + "src.core.auto_doc_updater.AutoDocUpdater", _FakeUpdater + ) + + from src.core.intelligence.jobs import job_manager + + # Сбрасываем возможный мусор от других тестов + for _j in list(job_manager.jobs): + if _j.startswith("rj_state"): + job_manager.jobs.pop(_j, None) + + # Запускаем через публичный путь trigger_async_reindex (запуск задачи) + job_id = await layer.trigger_async_reindex() + try: + for _ in range(100): + j = job_manager.get_job(job_id) + if j and j.status in ("completed", "failed"): + break + await asyncio.sleep(0.05) + finally: + job_manager.jobs.pop(job_id, None) + + assert states, "reindex job не вызвал set_state READY" + assert states[-1][1] is ProjectState.READY, f"state={states[-1][1]}" diff --git a/tests/test_search_bs_audit.py b/tests/test_search_bs_audit.py index f839b433..609689ce 100644 --- a/tests/test_search_bs_audit.py +++ b/tests/test_search_bs_audit.py @@ -98,7 +98,10 @@ def test_bs3_vector_search_carries_start_line(): def test_bs3_format_results_renders_1based_line(): - """Рендер конвертирует 0-based start_line в 1-based (L27, а не 0/2).""" + """Рендер отображает корректную 1-based строку (L27, а не chunk_index 0/2). + + start_line теперь хранится 1-based (реальная строка файла), конверсия + больше не нужна — см. фикс off-by-one (parser.py capture +1).""" raw = { "results": [ { @@ -106,7 +109,7 @@ def test_bs3_format_results_renders_1based_line(): "metadata": { "file": "src/database.py", "chunk_index": 0, - "start_line": 26, + "start_line": 27, "end_line": 28, "layer": "core", }, @@ -470,7 +473,7 @@ async def test_bs7_cypher_accepts_query_param(): tool = GraphQueryTool.__new__(GraphQueryTool) captured = {} - async def fake_cypher(query, kwargs): + async def fake_cypher(query, kwargs, project_root=""): captured["query"] = query return {"status": "ok", "action": "cypher", "query": query, "results": []} @@ -496,7 +499,7 @@ async def test_bs7_flow_accepts_name_param(): tool = GraphQueryTool.__new__(GraphQueryTool) captured = {} - async def fake_flow(name, kwargs): + async def fake_flow(name, kwargs, project_root=""): captured["name"] = name return {"status": "ok", "action": "flow", "variable": name} @@ -519,7 +522,7 @@ async def test_bs7_default_action_still_query(): tool = GraphQueryTool.__new__(GraphQueryTool) - async def fake_query(query_type, target, kwargs): + async def fake_query(query_type, target, kwargs, project_root=""): return {"status": "ok", "action": "query", "query_type": query_type, "target": target} tool._execute_query = fake_query diff --git a/tests/test_symbol_index_persistence.py b/tests/test_symbol_index_persistence.py new file mode 100644 index 00000000..5b6b2925 --- /dev/null +++ b/tests/test_symbol_index_persistence.py @@ -0,0 +1,97 @@ +"""Guard for SymbolIndex JSON persistence (2026-08-26 incident). + +Covers two corruption triggers found in index_guard.save_symbol_index: +1. graph-backed SymbolIndexAdapter (no _definitions/_references/_file_to_symbols + maps) must NOT write empty JSON over a populated file — graph.db is its + persistence. +2. An empty plain SymbolIndex must NOT overwrite a non-empty file on disk. +""" +import json + +import pytest + +from src.core.graph import PropertyGraph +from src.core.indexing.index_guard import IndexGuard +from src.core.indexing.symbol_index import SymbolIndex +from src.core.search.graph_adapter import SymbolIndexAdapter + + +class _FakeSymbolRef: + def __init__(self, symbol: str, file: str, line: int = 1, kind: str = "def", is_def: bool = True): + self.symbol = symbol + self.file = file + self.line = line + self.kind = kind + self.is_def = is_def + + def to_dict(self) -> dict: + return { + "symbol": self.symbol, + "file": self.file, + "line": self.line, + "kind": self.kind, + "is_def": self.is_def, + } + + +@pytest.fixture() +def guard(tmp_path): + return IndexGuard(db_path=tmp_path, project_path=tmp_path) + + +def _make_symbol_index() -> SymbolIndex: + si = SymbolIndex() + si._definitions["UserAuthService"] = [ + _FakeSymbolRef("UserAuthService", "src/auth/service.py") + ] + si._references["login"] = [ + _FakeSymbolRef("login", "src/api/v1/auth_router.py", is_def=False) + ] + si._file_to_symbols["src/auth/service.py"] = {"UserAuthService"} + return si + + +def test_save_populated_and_load_roundtrip(guard): + si = _make_symbol_index() + assert guard.save_symbol_index(si) is True + cache = guard.db_path / "symbol_index.json" + assert cache.exists() + raw = json.loads(cache.read_text(encoding="utf-8")) + assert raw["definitions"]["UserAuthService"][0]["file"] == "src/auth/service.py" + + loaded = SymbolIndex() + assert guard.load_symbol_index(loaded) is True + assert loaded.get_symbol_count() == 1 + + +def test_empty_instance_does_not_overwrite_populated_file(guard): + si = _make_symbol_index() + assert guard.save_symbol_index(si) is True + cache = guard.db_path / "symbol_index.json" + before = cache.read_text(encoding="utf-8") + + empty = SymbolIndex() # пустые все три карты + assert guard.save_symbol_index(empty) is True # не упал + assert cache.read_text(encoding="utf-8") == before # файл НЕ перезаписан + + +def test_adapter_does_not_write_empty_json(guard, tmp_path): + si = _make_symbol_index() + assert guard.save_symbol_index(si) is True + cache = guard.db_path / "symbol_index.json" + before = cache.read_text(encoding="utf-8") + + # graph-backed adapter: не имеет _definitions/_references -> обязан пропустить запись + pg = PropertyGraph(tmp_path / "graph.db") + adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) + assert guard.save_symbol_index(adapter) is True + assert cache.read_text(encoding="utf-8") == before # файл не тронут + + +def test_empty_file_can_be_created_for_fresh_project(guard): + empty = SymbolIndex() + assert guard.save_symbol_index(empty) is True + cache = guard.db_path / "symbol_index.json" + assert cache.exists() + raw = json.loads(cache.read_text(encoding="utf-8")) + assert raw["definitions"] == {} # свежий проект без данных — пустой файл легитимен diff --git a/tests/test_symbol_index_persistence_e4.py b/tests/test_symbol_index_persistence_e4.py new file mode 100644 index 00000000..2baecb07 --- /dev/null +++ b/tests/test_symbol_index_persistence_e4.py @@ -0,0 +1,94 @@ +""" +Regression test для E4 FIX: SymbolIndex persistence (PURE vs HYBRID). + +Вариант А (двухуровневая защита): +1. Explicit Guard: _mode == "pure" → skip JSON save (graph.db is truth). +2. Anti-corruption Backup: _definitions пуст И graph.count_nodes() > 0 → skip. + +Тесты: +- test_pure_skip_save: PURE adapter НЕ пишет symbol_index.json +- test_hybrid_normal_save: HYBRID adapter пишет непустой symbol_index.json +- test_raw_symbol_index_save: сырой SymbolIndex пишет нормально +""" +import sys +import json +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.core.search.graph_adapter import SymbolIndexAdapter +from src.core.graph import PropertyGraph +from src.core.indexing.symbol_index import SymbolIndex +from src.core.indexing.index_guard import IndexGuard + + +def _make_guard_and_pg(): + tmp = Path(tempfile.mkdtemp()) + pg = PropertyGraph(db_path=tmp / "graph.db") + guard = IndexGuard(db_path=tmp, project_path=tmp) + return guard, pg, tmp + + +def test_pure_skip_save(): + """PURE adapter → save_symbol_index НЕ создаёт symbol_index.json.""" + guard, pg, tmp = _make_guard_and_pg() + adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE) + adapter.add_definitions("src/main.py", [ + {"name": "process", "line": 10, "kind": "function"}, + {"name": "Main", "line": 20, "kind": "class"}, + ]) + + result = guard.save_symbol_index(adapter) + + json_file = tmp / "symbol_index.json" + assert result is True, "save_symbol_index должен вернуть True" + assert not json_file.exists(), ( + f"PURE adapter НЕ должен писать JSON (graph.db is persistence), " + f"но файл создан: {json_file}" + ) + print(f"[PASS] test_pure_skip_save — JSON не создан, graph nodes={pg.count_nodes()}") + + +def test_hybrid_normal_save(): + """HYBRID adapter → save_symbol_index пишет НЕПУСТОЙ symbol_index.json.""" + guard, pg, tmp = _make_guard_and_pg() + adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_HYBRID) + adapter.add_definitions("src/main.py", [ + {"name": "process", "line": 10, "kind": "function"}, + {"name": "Main", "line": 20, "kind": "class"}, + ]) + + result = guard.save_symbol_index(adapter) + + json_file = tmp / "symbol_index.json" + assert result is True, "save_symbol_index должен вернуть True" + assert json_file.exists(), "HYBRID adapter ДОЛЖЕН писать JSON" + data = json.loads(json_file.read_text(encoding="utf-8")) + assert len(data.get("definitions", {})) == 2, ( + f"HYBRID должен сохранить 2 определения, получено: {data}" + ) + print(f"[PASS] test_hybrid_normal_save — JSON с {len(data['definitions'])} defs") + + +def test_raw_symbol_index_save(): + """Сырой SymbolIndex (не adapter) → пишет нормально.""" + guard, pg, tmp = _make_guard_and_pg() + si = SymbolIndex() + si.add_definitions("src/x.py", [{"name": "foo", "line": 1, "kind": "function"}]) + + result = guard.save_symbol_index(si) + + json_file = tmp / "symbol_index.json" + assert result is True + assert json_file.exists(), "Raw SymbolIndex ДОЛЖЕН писать JSON" + data = json.loads(json_file.read_text(encoding="utf-8")) + assert len(data.get("definitions", {})) == 1, "Raw SymbolIndex должен сохранить 1 def" + print(f"[PASS] test_raw_symbol_index_save — JSON с 1 def") + + +if __name__ == "__main__": + test_pure_skip_save() + test_hybrid_normal_save() + test_raw_symbol_index_save() + print("\nALL TESTS PASSED") diff --git a/tests/test_ui_formatter_dim.py b/tests/test_ui_formatter_dim.py index 6a814c2d..fb7ec81e 100644 --- a/tests/test_ui_formatter_dim.py +++ b/tests/test_ui_formatter_dim.py @@ -59,6 +59,69 @@ def test_format_runtime_status_fallback_to_768_when_no_model_info(): assert "768dim" in result, f"Expected fallback 768dim, got:\n{result}" +def test_format_runtime_status_shows_reindex_in_progress(): + """Вариант А (2026-08-25): при переиндексации показываем «Reindex in + progress (N%)», а НЕ ложное «0 chunks» — иначе агент решит что индекс + пуст и запустит второй reindex.""" + data = { + "embedding_provider": "llama_cpp", + "provider_status": { + "onnx_local_engine": "not_loaded", + "lm_studio_at_1234": "offline", + "ollama_at_11434": "offline", + }, + "index_telemetry": { + "total_chunks": 0, + "unique_files": 0, + "symbol_index_count": 0, + "status": "reindexing", + "reindex_in_progress": True, + "reindex_progress_pct": 62, + "reindex_eta_sec": 264, + }, + } + result = format_runtime_status(data) + assert "Reindex in progress (62%)" in result, f"Expected reindex line, got:\n{result}" + assert "0 chunks" not in result, f"Must NOT show 0 chunks during reindex:\n{result}" + + +def test_format_runtime_status_reindex_without_progress(): + """Reindex без данных job'а (прогресс ещё не появился/нет job) — + форматтер не падает и не врёт цифрами.""" + data = { + "embedding_provider": "llama_cpp", + "provider_status": {}, # пусто — formatter не падает + "index_telemetry": { + "total_chunks": 0, + "status": "reindexing", + "reindex_in_progress": True, + "reindex_progress_pct": None, + "reindex_eta_sec": None, + }, + } + result = format_runtime_status(data) + assert "Reindex in progress" in result, f"Expected reindex line, got:\n{result}" + assert "0 chunks" not in result, f"Must NOT show 0 chunks during reindex:\n{result}" + + +def test_format_runtime_status_normal_path_unchanged(): + """Без reindex-флагов поведение прежнее (0 chunks для пустого индекса).""" + data = { + "embedding_provider": "llama_cpp", + "provider_status": { + "onnx_local_engine": "not_loaded", + "lm_studio_at_1234": "offline", + "ollama_at_11434": "offline", + }, + "index_telemetry": { + "total_chunks": 0, + "unique_files": 0, + }, + } + result = format_runtime_status(data) + assert "0 chunks" in result, f"Expected 0 chunks without reindex, got:\n{result}" + + if __name__ == "__main__": test_format_runtime_status_shows_real_dimension() test_format_runtime_status_fallback_to_768_when_no_model_info() diff --git a/tools/stale_detector/stale_config.json b/tools/stale_detector/stale_config.json index 2f0e5803..80aa7767 100644 --- a/tools/stale_detector/stale_config.json +++ b/tools/stale_detector/stale_config.json @@ -8,7 +8,8 @@ "ISSUE.md", "WISDOM.md", "owp_rfc_001_v04.md", - "owp_github_discussion_draft.md" + "owp_github_discussion_draft.md", + "ARCLUX_AUDIT_2026-08-26.md" ], "exclude_dirs": [ "research",