diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index afde58b1..1d8b8f22 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -50,6 +50,17 @@ - **Чёрные окна CMD (2026-08-14):** MCP запускался как `venv\Scripts\python.exe` (console-подсистема) → каждое окно Zed = своё чёрное окно; фикс: `pythonw.exe` в extension.toml + CREATE_NO_WINDOW во ВСЕХ runtime subprocess (13 файлов) — с pythonw (нет консоли) незакрытые git/wmic/netstat мигали бы окнами - **FA=0.00 ≠ качество guardrail (2026-08-15):** Exp 1-L Day 3 — qwen3.6/3.7 (zero-shot VOR) достигают FA=0.00 ценой recall(real)=0.08–0.20 (code_first: 2/25 правды принято, 7/25 активно отвергнуто) — fail-closed политика, а не «фильтрация лжи»; выбор LLM для verify-on-read = выбор политики (fail-closed qwen vs max-coverage glm), recall(real) обязан быть в метриках. CoT (V3/Part 5) НЕ окупается: только qwen3.6 recall 0.08→0.20 при цене ×30–65 +## [2026-09-25] Reindex deadlock + concurrent indexers + embedder lease — Fixed (live) +**Status:** ✅ Fixed + live-verified (full reindex completed; index collapse confirmed). +**Root Cause:** `_bounded_link` (timeout fix 2026-09-25) ran `bulk_write` on a NEW daemon thread, while `run()` held the global write RLock on the caller thread for the whole reindex. `bulk_write` acquires the SAME RLock → permanent deadlock (py-spy: "bounded-bulk_write" idle at `db_writer.py:336`; job stuck 52% "running", 0 CPU). Timeout only fired at 300s → job failed. Same class for prune/verify (recreate_table_physical). +**Fix:** `_bounded_link` now wraps the bounded call in `_suspend_write_lock()` (same remedy as `_safe_ivf_index`). One place, covers all links. Guard: `tests/test_bounded_link_deadlock.py` (deterministic, negative control fails-fast at bound). +**Superseded (same day):** the `_suspend_write_lock` remedy *released* the global RLock and exposed a 2nd bug — auto-index + manual trigger ran TWO indexers concurrently (py-spy: two threads in `index_project`). Proper fix: **split the locks** — `run()` holds `begin_run()` (separate non-reentrant Lock) for run exclusion; `_table_write_lock` is acquired per DB op; `_bounded_link` no longer suspends. Guards: `test_run_singleflight.py`, updated `test_bounded_link_deadlock.py`. +**Live result:** full reindex `c09c2e22` → **completed 858.5s**; index **19653 → 10103**; path-duplication **668 → 0**; dup(file_path,chunk_index) **144 → 0**. +**Also:** `src/core/reindex_ledger.py` — durable `/logs/reindex_ledger.jsonl` recording start/phase/error+traceback/zombie/end; `layer.py` guarantees terminal status + watchdog (`MSCODEBASE_REINDEX_STALL_SEC=900`). First real row captured the deadlock traceback → no more guessing. +**Second incident (same session):** 22:09 a *devbase* MCP server spawned and started its own llama-server on fixed :8080/:8081; our MSCodeBase server died hard mid-parse (ledger: start, no end; driver got `ClosedResourceError`). Confirms fixed-port / multi-window conflict class (see KNOWN_ISSUES). +**Files:** `src/core/indexing/index_project_runner.py`, `src/core/reindex_ledger.py`, `src/core/intelligence/layer.py`, tests. +**verified_from_clean_state:** ⚠️ не проверено (локальный pytest: 8 passed; live reindex прерван крахом сервера). + ## [2026-09-22] E17 v7 — CORRECTION: AST proxies do not predict kill-rate (v6 not replicated) **Status:** Measured (v6 REFUTED on replication). diff --git a/EXPERIMENTS_LOG.md b/EXPERIMENTS_LOG.md index 4dea6b09..e16f20ed 100644 --- a/EXPERIMENTS_LOG.md +++ b/EXPERIMENTS_LOG.md @@ -1,5 +1,23 @@ # EXPERIMENTS_LOG.md — Audit Verification (2026-07-22) +## [2026-09-25] — E18: graph write throughput — per-entity transaction vs batched (CONFIRMED) + +**Гипотеза:** `PropertyGraph.add_node/add_edge` открывают ОДНУ SQLite-транзакцию + named-mutex на каждый узел/ребро (`graph.py:526-544, 816-851`) → graph-сборка сериализована и I/O-bound; это root cause «CPU 5% + диск ~3 МБ/с» на фазе parsing. +**Команда:** ` experiments/misc_probes/exp_graph_write_throughput.py` (4 руки, temp-БД, одинаковый INSERT SQL). +**Сырой вывод (после фикса, arm D = реальный `PropertyGraph.batch()`):** +``` +N_NODES=2000 N_EDGES=4000 +A per-call PropertyGraph (mutex+txn) : 20.58s 194 entities/s +B raw, ONE transaction+reused : 0.03s 140875 entities/s +C raw, per-node transaction : 16.89s 237 entities/s +D PropertyGraph.batch() [the fix] : 0.15s 26826 entities/s +speedup D/A (the fix) = 138.0x ; speedup B/A = 724.8x +negative control counts equal: True -> [(2000, 2000)] +``` +**Вердикт:** CONFIRMED. Per-row commit/fsync — доминанта; named-mutex добавляет ~35%. Отрицательный контроль — counts идентичны (нет порчи). Внешний baseline совпадает: SQLite 429 rows/s отдельными транзакциями vs 2.457M rows/s (одна транзакция+reused) — voidstar.tech. +**Фикс внедрён (2026-09-25):** `PropertyGraph.batch()` (одна tx на файл) + `indexer._parse_file_only` оборачивает graph-update файла; измерено **138× на реальном API**. Guard: `tests/test_graph_batch.py` (counts/атомарность/вложенность/персистентность). +**Live-контроль (job 268ac11b, 2026-09-26):** первая попытка на `batch()` **упала на finalize** (`cannot start a transaction within a transaction` в `GraphSymbolResolver.resolve_all`): `batch()` хранил состояние глобально, а parse идёт в 4 потока — файлы делили одну транзакцию. Фикс: владелец батча по thread-id (`_batch_owner`); чужой поток не присоединяется, а блокируется и получает свой батч (проверка и в add_node/add_edge/delete_node). Guard: `test_concurrent_batches_do_not_share_a_transaction` (Barrier, 2 потока, без OperationalError, counts). Повторный прогон → **completed 603с, 10106/10106**, path-dup 0. + ## [2026-09-20] — E13 / поискочное качество: 6 исследовательских задач (план) **Статус:** Plan (код не тронут; задачи в ISSUE.md KI-R1..R6) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index c5e820dc..bce03fa5 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -1,248 +1,296 @@ -# KNOWN ISSUES — MSCodeBase Intelligence +# KNOWN ISSUES тАФ MSCodeBase Intelligence -> Синхронизируется из `AGENT_DIARY.md` при каждом [🏁 ИТОГ]. -> Формат: дата | что было | статус | fix +> ╨б╨╕╨╜╤Е╤А╨╛╨╜╨╕╨╖╨╕╤А╤Г╨╡╤В╤Б╤П ╨╕╨╖ `AGENT_DIARY.md` ╨┐╤А╨╕ ╨║╨░╨╢╨┤╨╛╨╝ [ЁЯПБ ╨Ш╨в╨Ю╨У]. +> ╨д╨╛╤А╨╝╨░╤В: ╨┤╨░╤В╨░ | ╤З╤В╨╛ ╨▒╤Л╨╗╨╛ | ╤Б╤В╨░╤В╤Г╤Б | fix --- -**30 entries** — compressed per §4.8 R3 (conclusion-first; dedup 2026-09-08, 2026-09-21) +**35 entries** тАФ compressed per ┬з4.8 R3 (conclusion-first; dedup 2026-09-08, 2026-09-21) -## 2026-09-22 — TESTS-рёбра транзитивны, а не «тесты про функцию»; E17 LLM-pilot сломан на извлечении кода (Fixed / Open) +## 2026-09-25 тАФ Reindex deadlock: `_bounded_link` ran `bulk_write` on a new thread while the caller held the write RLock (Fixed) -- **Источник:** AGENT_DIARY.md#2026-09-22 (E17 Post-Mortem), `bootstrap_tests.py:216-244` -- **Описание:** (1) dynamic-trace линкует тест с *каждой исполненной* функцией → `_ensure_data_root` имеет 234 TESTS-ребра при 0 прямых вызовов в `tests/` (`check_disk_space` — 3). Для LLM-контекста сэмпл из 234 — шум, поэтому B-арм ≈ D-арм. (2) `e17_pilot_{answers,judge}.py` извлекали код наивным `f"def {name}"`, а граф хранит qualifed-имена (`Class.method`, `Class::test`) → `# FUNC NOT FOUND` для всех методов, `C_static` пуст 30/30. -- **Fix:** AST-извлечение в `experiments/bootstrap/e17_extract.py` + 13 тестов (Fixed). Фильтр TESTS по специфичности (прямой вызов / малый coverage-set) и пересборка pilot_data — не сделаны. -- **Статус:** Fixed (extraction) / Open (specificity-фильтр блокирует валидный E17 LLM-pilot). v3.5.0 retrieval hit@1 не задет. +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** live job `090149f1` (stuck 52% "running", 0 CPU); `py-spy dump 6780` тЖТ ╨┐╨╛╤В╨╛╨║ `bounded-bulk_write` idle ╨╜╨░ `db_writer.py:336` (`with self._table_write_lock:`), ╨┐╨╛╤В╨╛╨║ `asyncio_1` ╨╢╨┤╤С╤В ╨╡╨│╨╛ ╨▓ `_bounded_link`; `reindex_ledger.jsonl` ╨╖╨░╨┐╨╕╤Б╨░╨╗ `RuntimeError: bulk_write exceeded 300s`. +- **Root Cause:** `run()` ╨┤╨╡╤А╨╢╨╕╤В ╨│╨╗╨╛╨▒╨░╨╗╤М╨╜╤Л╨╣ RLock (`db_manager.begin_write()`) ╨▓╨╡╤Б╤М reindex ╨╜╨░ ╤Б╨▓╨╛╤С╨╝ ╨┐╨╛╤В╨╛╨║╨╡; `_bounded_link` (timeout-╤Д╨╕╨║╤Б 2026-09-25) ╨╖╨░╨┐╤Г╤Б╨║╨░╨╗ `bulk_write` ╨▓ ╨Э╨Ю╨Т╨Ю╨Ь daemon-╨┐╨╛╤В╨╛╨║╨╡, ╨░ `bulk_write` ╨▒╨╡╤А╤С╤В ╨в╨Ю╨в ╨Ц╨Х RLock тЖТ ╨┤╨╡╨┤╨╗╨╛╨║. ╨в╨╛╤В ╨╢╨╡ ╨║╨╗╨░╤Б╤Б ╨┤╨╗╤П `prune`/`verify` (`recreate_table_physical`). +- **Fix:** `_bounded_link` ╨╛╨▒╨╛╤А╨░╤З╨╕╨▓╨░╨╡╤В bounded-╨▓╤Л╨╖╨╛╨▓ ╨▓ `_suspend_write_lock()` (╤Г╨╢╨╡ ╨┐╤А╨╕╨╝╨╡╨╜╤С╨╜╨╜╤Л╨╣ ╨┤╨╗╤П `_safe_ivf_index`) тАФ ╨╡╨┤╨╕╨╜╨░╤П ╤В╨╛╤З╨║╨░, ╨┐╨╛╨║╤А╤Л╨▓╨░╨╡╤В ╨▓╤Б╨╡ ╨╖╨▓╨╡╨╜╤М╤П. +- **Fix (╨╕╤В╨╛╨│):** ╤А╨░╨╖╨┤╨╡╨╗╨╡╨╜╤Л ╨┤╨▓╨░ ╨╝╤М╤О╤В╨╡╨║╤Б╨░ тАФ `run()` ╨┤╨╡╤А╨╢╨╕╤В ╨╛╤В╨┤╨╡╨╗╤М╨╜╤Л╨╣ `begin_run()` (non-reentrant, ╨▓╨╖╨░╨╕╨╝╨╜╨╛╨╡ ╨╕╤Б╨║╨╗╤О╤З╨╡╨╜╨╕╨╡ ╨╖╨░╨┐╤Г╤Б╨║╨╛╨▓), ╨░ `_table_write_lock` ╨▒╨╡╤А╤С╤В╤Б╤П ╤В╨╛╨╗╤М╨║╨╛ ╨╜╨░ ╨╛╨┐╨╡╤А╨░╤Ж╨╕╤О. `_bounded_link` ╨▒╨╛╨╗╤М╤И╨╡ ╨╜╨╡ ╨╛╤Б╨▓╨╛╨▒╨╛╨╢╨┤╨░╨╡╤В write-lock. ╨н╤В╨╛ ╨╖╨░╨║╤А╤Л╨╗╨╛ ╨╕ ╨┤╨╡╨┤╨╗╨╛╨║, ╨╕ ╨┐╨░╤А╨░╨╗╨╗╨╡╨╗╤М╨╜╤Л╨╡ ╨╕╨╜╨┤╨╡╨║╤Б╨░╤В╨╛╤А╤Л (auto-index + manual trigger) тАФ ╨╛╨╜╨╕ ╤В╨╡╨┐╨╡╤А╤М ╤Б╨╡╤А╨╕╨░╨╗╨╕╨╖╤Г╤О╤В╤Б╤П. +- **Guard:** `tests/test_bounded_link_deadlock.py` (write-lock ╨╜╨░ ╨┤╤А╤Г╨│╨╛╨╝ ╨┐╨╛╤В╨╛╨║╨╡ ╨╜╨╡ ╨┤╨╡╨┤╨╗╨╛╤З╨╕╤В + ╤Б╤В╤А╤Г╨║╤В╤Г╤А╨╜╤Л╨╣ ╨║╨╛╨╜╤В╤А╨░╨║╤В), `tests/test_run_singleflight.py` (begin_run тЙа begin_write; ╨▓╤В╨╛╤А╨╛╨╣ run ╨▒╨╗╨╛╨║╨╕╤А╤Г╨╡╤В╤Б╤П). +- **LIVE verified (2026-09-25):** full reindex job `c09c2e22` тЖТ **completed ╨╖╨░ 858.5╤Б** (ledger: parsingтЖТembeddingтЖТfinalizingтЖТcompleteтЖТend). ╨Ш╨╜╨┤╨╡╨║╤Б: **19653 тЖТ 10103**, path-duplication **668 тЖТ 0**, dup(file_path,chunk_index) **144 тЖТ 0**. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed + live-verified. -## 2026-09-19 — Прод-инцидент: миграция колонок lanceDB молча не выполнялась + db_writer разрушал БД при schema-mismatch (Fixed) +## 2026-09-25 тАФ ╨Я╨░╨┤╨╡╨╜╨╕╤П ╨╜╨╡ ╤Д╨╕╨║╤Б╨╕╤А╨╛╨▓╨░╨╗╨╕╤Б╤М: zombie-job + ╨│╨╗╤Г╤И╨╡╨╜╨╕╨╡ ╨╕╤Б╨║╨╗╤О╤З╨╡╨╜╨╕╨╣ + ╨╜╨╡╤В ledger (Fixed) / Open (server hard-death) -- **Источник:** AGENT_DIARY.md#2026-09-19 -- **Описание:** два бага, найденные при E10-исследовании поиска (индекс строился на свежей БД, миграция молча не срабатывала): - 1. Старый `db_manager` импортировал `_migrate_text_full_inplace` / `_migrate_add_metadata_columns` из `indexer_table.py` как module-level функции, а это методы класса `IndexerTableMixin` (indexer_table.py:17,66,94) → ImportError → миграция НЕ выполнялась. - 2. `db_writer.is_table_missing` трактовал `"in table schema"` (schema-mismatch) как «таблица отсутствует» → ПОЛНЫЙ rebuild (drop + re-embed ~13 мин) вместо soft-миграции. -- **Fix:** `db_manager` — локальные `_migrate_text_full_inplace(table)` / `_migrate_add_metadata_columns(existing_fields, table)` с `pa.field(name, field.type)` из `self.schema`; `db_writer` — `is_table_missing` исключает `"in table schema"` (recreate только при реальном отсутствии таблицы). +200 строк тестов (`tests/test_lancedb_recreate.py`): миграция legacy→file_mtime_ns/file_size, идемпотентность, «НЕ пересоздавать при schema-mismatch». -- **Статус:** ✅ Fixed (подготовлен к PR в этом коммите). Тесты: test_lancedb_recreate 12 passed, фокус-группа 43 passed. +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** job `e4977ded` (running, ╨╜╨╛ py-spy: 0 ╨▓╨╛╤А╨║╨╡╤А╨╛╨▓), `layer.py:863` `"Exception suppressed at layer.py: ..."` ╨▒╨╡╨╖ ╤Б╤В╨╡╨║╨░; `job_manager` тАФ in-memory. +- **Fix:** `src/core/reindex_ledger.py` (durable JSONL start/phase/error+traceback/zombie/end, ╨╜╨╕╨║╨╛╨│╨┤╨░ ╨╜╨╡ ╨▒╤А╨╛╤Б╨░╨╡╤В); `layer.py` тАФ `finally` ╨│╨░╤А╨░╨╜╤В╨╕╤А╤Г╨╡╤В ╤В╨╡╤А╨╝╨╕╨╜╨░╨╗╤М╨╜╤Л╨╣ ╤Б╤В╨░╤В╤Г╤Б, `_watchdog_reindex` ╤В╨╡╤А╨╝╨╕╨╜╨░╨╗╨╕╨╖╨╕╤А╤Г╨╡╤В ╨╖╨░╤Б╤В╤А╤П╨▓╤И╨╕╨╣ job (task done / ╨╜╨╡╤В ╨┐╤А╨╛╨│╤А╨╡╤Б╤Б╨░ > `MSCODEBASE_REINDEX_STALL_SEC`=900), ╨┐╨╛╨╗╨╜╤Л╨╣ traceback ╨▓╨╝╨╡╤Б╤В╨╛ ┬лsuppressed┬╗. Guard `tests/test_reindex_ledger.py` (6, ╤Б negative control). +- **Open:** 22:09 ╨╜╨░╤И MSCodeBase-╤Б╨╡╤А╨▓╨╡╤А **╤Г╨╝╨╡╤А ╨╢╤С╤Б╤В╨║╨╛** (ledger: start ╨▒╨╡╨╖ end; ╨┤╤А╨░╨╣╨▓╨╡╤А `ClosedResourceError`) ╨▓ ╨╝╨╛╨╝╨╡╨╜╤В, ╨║╨╛╨│╨┤╨░ ╨┐╨╛╨┤╨╜╤П╨╗╤Б╤П MCP-╤Б╨╡╤А╨▓╨╡╤А ╨┤╨╗╤П **devbase** ╨╕ ╨╖╨░╨╜╤П╨╗ ╤Д╨╕╨║╤Б╨╕╤А╨╛╨▓╨░╨╜╨╜╤Л╨╡ :8080/:8081. ╨Ъ╨╗╨░╤Б╤Б ┬л╤Д╨╕╨║╤Б╨╕╤А╨╛╨▓╨░╨╜╨╜╤Л╨╡ ╨┐╨╛╤А╤В╤Л / ╨╝╤Г╨╗╤М╤В╨╕╨╛╨║╨╜╨╛ / ╤А╨░╨╖╨┤╨╡╨╗╤П╨╡╨╝╤Л╨╣ ╤Н╨╝╨▒╨╡╨┤╨┤╨╡╤А ╨▒╨╡╨╖ ref-count┬╗ тАФ ╨┐╤А╨╕╤З╨╕╨╜╨░ ┬л╨┐╨╛╤Б╤В╨╛╤П╨╜╨╜╨╛ ╨┐╨░╨┤╨░╨╡╤В┬╗. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed (recording) / ЁЯФм Open (server hard-death ╨┐╤А╨╕ ╨╝╤Г╨╗╤М╤В╨╕╨╛╨║╨╜╨╡; ╨╜╤Г╨╢╨╡╨╜ supervisor/╨┤╨╕╨╜╨░╨╝╨╕╤З╨╡╤Б╨║╨╕╨╡ ╨┐╨╛╤А╤В╤Л/ref-count). -## 2026-09-19 — E10 (search quality): full-text-эмбеддинг + e5-префиксы + пул reranker 50 → REFUTED (N=10) +## 2026-09-25 тАФ ╨а╨░╨╖╨┤╤Г╨▓╨░╨╜╨╕╨╡ ╨╕╨╜╨┤╨╡╨║╤Б╨░ ~├Ч2: full-reindex ╨┐╨╕╤Б╨░╨╗ `\`, incremental тАФ `/` (╨╛╨┤╨╕╨╜ ╤Д╨░╨╣╨╗ = ╨┤╨▓╨╡ ╤Б╤В╤А╨╛╨║╨╕) (Fixed) -- **Источник:** EXPERIMENTS_LOG.md#2026-09-19 -- **Описание:** три «выключателя» качества (E10a full-text чанка в эмбеддинг, e5 `query:`/`passage:`-префиксы в llama.cpp-ветке — ONNX/OpenVINO уже имели `_ensure_prefix`, E10c пул reranker 30→50) не дали подтверждаемого сдвига. Чистый прогон (599 файлов / 9514 чанков, 799.9s): fast hit@1=0% hit@5=50%; quality hit@1=20% hit@5=40%; baseline автора 0/50% и 30/30%. Дельта — в пределах шума N=10. -- **Fix (предотвращение):** изменённый код откален к HEAD (поведение клиента = прод); остаток — env-тумблер `MAX_RERANKER_INPUT` с default=30 (нейтрален). Платo «pure-vector» подтверждено повторно (ср. Exp-29 ceiling ~0.23). -- **Статус:** ❌ REFUTED (закрыт, записан в lab exp-43). Следующий ход — AST/Graph-hybrid re-ranking, не эмбеддинговые твики. +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** ╤Б╨╜╨╕╨╝╨╛╨║ ╨╕╨╜╨┤╨╡╨║╤Б╨░ (`experiments/misc_probes/exp_index_dedup_probe.py`): `file_path` distinct raw=**1378** vs normalized=**710** (path-duplication **668**); ╨║╨╛╨┤: `index_project_runner._parse_worker` (`str(relative_to)` тЖТ `\`), `freshness.py:96` (`.replace(os.sep,"/")`), `db_writer` id=`md5(rel_path)_i`, `indexer._parse_file_only` (`known_hashes.get(rel_path_str)`). +- **Root Cause:** ╨┐╨╛╨╗╨╜╤Л╨╣ reindex ╨┐╨╕╤Б╨░╨╗ ╨┐╤Г╤В╨╕ ╤Б `\`, hot-reload/freshness тАФ ╤Б `/`. `known_hashes` ╨╕ id ╤Б╤В╤А╨╛╨║╨╕ ╤Б╤В╤А╨╛╤П╤В╤Б╤П ╨┐╨╛ **╨▒╤Г╨║╨▓╨░╨╗╤М╨╜╨╛╨╝╤Г** `file_path` тЖТ ╤Д╨╛╤А╨╝╤Л ╨╜╨╡ ╤Б╨╛╨▓╨┐╨░╨┤╨░╨╗╨╕ тЖТ incremental **╨┐╨╡╤А╨╡-╨┤╨╛╨▒╨░╨▓╨╗╤П╨╗** ╤Г╨╢╨╡ ╨┐╤А╨╛╨╕╨╜╨┤╨╡╨║╤Б╨╕╤А╨╛╨▓╨░╨╜╨╜╤Л╨╡ ╤Д╨░╨╣╨╗╤Л ╨║╨░╨╢╨┤╤Л╨╣ ╨┐╤А╨╛╨│╨╛╨╜ тЖТ ╤А╨╛╤Б╤В ~├Ч2. ╨Ш╨╖╨╝╨╡╤А╨╡╨╜╨╛: `9991 тЖТ 19653` ╤З╨░╨╜╨║╨╛╨▓. +- **╨Т╤В╨╛╤А╨░╤П ╨┐╤А╨╕╤З╨╕╨╜╨░:** data-JSON тАФ **5272 ╤З╨░╨╜╨║╨░ (27%)**, ╨║╤А╤Г╨┐╨╜╤Л╨╡ `results_*.json` (╨┤╨╛ 1002 ╤З╨░╨╜╨║╨╛╨▓ ╨╜╨░ ╤Д╨░╨╣╨╗). +- **Fix:** ╨║╨░╨╜╨╛╨╜╨╕╤З╨╡╤Б╨║╨╕╨╣ POSIX ╤З╨╡╤А╨╡╨╖ `src/core/relpath.py::normalize_rel_path`, ╨┐╤А╨╕╨╝╨╡╨╜╤С╨╜ ╨▓ `indexer._parse_file_only` (choke: rel + ╨╕╨╖╨▓╨╡╤Б╤В╨╜╤Л╨╡ ╤Е╤Н╤И╨╕), `db_writer.write_records`/`prepare_records`, `index_project_runner` (known_hashes load), `indexing_tools.notify_change`. **T3-╤Б╨▓╨╕╨┐ (╨╛╨▒╨╛╨▒╤Й╨╡╨╜╨╕╨╡) ╨╜╨░╤И╤С╨╗ ╨╡╤Й╤С 2 ╨║╤А╨╕╤В╨╕╤З╨╜╤Л╤Е ╨╕╨╜╨┤╨╡╨║╤Б-╨┐╨╕╤В╨░╤О╤Й╨╕╤Е ╨╝╨╡╤Б╤В╨░:** `indexer.index_file` (`:837`) ╨╕ `index_project_runner._parse_worker` тЖТ `current_files_on_disk` (╨▓╤Е╨╛╨┤ prune) тАФ ╨▒╨╡╨╖ ╨╜╨╛╤А╨╝╨░╨╗╨╕╨╖╨░╤Ж╨╕╨╕ prune-╨╝╨╜╨╛╨╢╨╡╤Б╤В╨▓╨╛ (`\`) ╨╜╨╡ ╤Б╨╛╨▓╨┐╨░╨╗╨╛ ╨▒╤Л ╤Б ╨С╨Ф (`/`) (╤Б╨┐╨░╤Б╨░╨╗ safety-guard >50%); ╨╜╨╛╤А╨╝╨░╨╗╨╕╨╖╨╛╨▓╨░╨╜╤Л. ╨Я╤А╨╛╤З╨╕╨╡ `str(relative_to)` тАФ doc/display (╨╜╨╡ ╨╕╨╜╨┤╨╡╨║╤Б), ╨║ ╤А╨╡╨▓╨╕╨╖╨╕╨╕ ╨╛╤В╨┤╨╡╨╗╤М╨╜╨╛. +- **Guard:** `tests/test_relpath.py` (5); resume-╤В╨╡╤Б╤В ╨╛╨▒╨╜╨╛╨▓╨╗╤С╨╜ ╨┐╨╛╨┤ ╨║╨░╨╜╨╛╨╜╨╕╤З╨╡╤Б╨║╨╕╨╣ ╨┐╤Г╤В╤М; 27 passed; ruff clean. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed + **live-verified collapse**: full reindex тЖТ 19653 тЖТ **10103** rows, path-duplication **668 тЖТ 0**, dup(file_path,chunk_index) **144 тЖТ 0**. ╨Ш╤Б╨║╨╗╤О╤З╨╡╨╜╨╕╨╡ data-JSON (5272 ╤З╨░╨╜╨║╨░) тАФ ╨╛╤В╨┤╨╡╨╗╤М╨╜╨╛. -## 2026-09-18 — Фаза 1: Incremental Hot-Reload — FreshnessChecker оживлён, hot-reload зашит (AST+FTS5+граф) +## 2026-09-25 тАФ graph.db lock ╨╖╨░╨▓╨░╨╗╨╕╨▓╨░╨╗ reindex: named mutex ╨▓╨╗╨░╨┤╨╡╨╡╤В╤Б╤П ╨Я╨Ю╨в╨Ю╨Ъ╨Ю╨Ь, ╨▓╨╜╤Г╤В╤А╨╕╨┐╤А╨╛╤Ж╨╡╤Б╤Б╨╜╨╛╨│╨╛ lock ╨╜╨╡ ╨▒╤Л╨╗╨╛ (Fixed) -- **Источник:** AGENT_DIARY.md#2026-09-18 -- **Описание:** FreshnessChecker был мёртв (0 вызовов) и сломан: `to_pandas(columns=)` падает на lancedb 0.34 (рабочее — `to_lance().to_pandas`, 102.2ms/10366 напр.); пропускал НОВЫЕ файлы (KI-109 — файл без notify_change не попадал в индекс); передавал `project_path` вместо `rel` в `_index_single_file`. Хот-reload существовал, но оставлял 3 дыры: FTS5 (incremental_update_fts5 только добавляет), PropertyGraph (remove_file не вызывался), schema (не было mtime/size для stat-first). -- **Fix:** schema + `file_mtime_ns`/`file_size` (db_manager/indexer_table/db_writer/index_pipeline/indexer); FreshnessChecker.verify переписан: stat-first (mtime+size → skip без hash), hash-подтверждение для несовпадений/legacy, новые файлы индексируются, debounce (`FRESHNESS_INTERVAL_SEC`, 0=выкл) + Lock + `is_reindexing`-гейт, фильтрация через real FileGuard; `_index_single_file`: +remove_file (граф) +remove_from_fts5 (FTS5) перед переиндексацией; search-хук `_maybe_hot_reload` (await `asyncio.to_thread`). Полный reindex остаётся fallback (срабатывает при пустом индексе). 6 новых тестов + 233 регресс-прохода. -- **Статус:** ✅ Fixed локально (ветка не запушена, verified_from_clean_state не прогонялся). 7 новых тестов (включая concurrency-стресс N=16) + 1748 полный pytest green. Полный reindex-запуск после миграции существующих БД не выполнялся — запрос владельцу на live-check. +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** reindex `cb8305f7` failed ┬лCould not acquire cross-process lock for graph.db within 30000ms┬╗; `src/core/graph.py:46-128`; `experiments/misc_probes/exp_graph_mutex_cross_thread.py`; `tests/test_graph_lock_threadsafe.py` +- **Root Cause:** `_cross_process_lock` ╨╕╤Б╨┐╨╛╨╗╤М╨╖╨╛╨▓╨░╨╗ **╤В╨╛╨╗╤М╨║╨╛** Windows named mutex. Named mutex ╨┐╤А╨╕╨╜╨░╨┤╨╗╨╡╨╢╨╕╤В **╨┐╨╛╤В╨╛╨║╤Г-╨▓╨╗╨░╨┤╨╡╨╗╤М╤Ж╤Г** ╨╕ ╨╜╨╡ ╤А╨╡╨╡╨╜╤В╨╡╤А╨░╨▒╨╡╨╗╨╡╨╜ ╨╝╨╡╨╢╨┤╤Г ╨┐╨╛╤В╨╛╨║╨░╨╝╨╕ тЖТ ╨▓╤В╨╛╤А╨╛╨╣ ╨┐╨╛╤В╨╛╨║ ╤В╨╛╨│╨╛ ╨╢╨╡ MCP-╨┐╤А╨╛╤Ж╨╡╤Б╤Б╨░ (╤А╨╡╨╕╨╜╨┤╨╡╨║╤Б-finalize vs ╨╢╨╕╨▓╨░╤П graph-╨╛╨┐╨╡╤А╨░╤Ж╨╕╤П) ╨╜╨╡ ╨┐╨╛╨╗╤Г╤З╨░╨╡╤В ╨╝╤Г╤В╨╡╨║╤Б ╨╕ ╨┐╨░╨┤╨░╨╡╤В ╤А╨╛╨▓╨╜╨╛ ╨┐╨╛ ╤В╨░╨╣╨╝╨░╤Г╤В╤Г. ╨н╨║╤Б╨┐╨╡╤А╨╕╨╝╨╡╨╜╤В: ╨┐╤А╨╕ ╤Г╨┤╨╡╤А╨╢╨░╨╜╨╕╨╕ 3╤Б ╨▓╤В╨╛╤А╨╛╨╣ ╨┐╨╛╤В╨╛╨║ ╨╛╤В╨║╨░╨╖╨░╨╗ ╨╜╨░ **0.80╤Б** (=╨╡╨│╨╛ ╤В╨░╨╣╨╝╨░╤Г╤В). ╨Ч╨╜╨░╤З╨╕╤В ╨╗╤О╨▒╨░╤П graph-╨╛╨┐╨╡╤А╨░╤Ж╨╕╤П >30╤Б ╨╖╨░╨▓╨░╨╗╨╕╨▓╨░╨╗╨░ ╤А╨╡╨╕╨╜╨┤╨╡╨║╤Б. +- **Fix:** ╨┤╨╛╨▒╨░╨▓╨╗╨╡╨╜ ╨▓╨╜╤Г╤В╤А╨╕╨┐╤А╨╛╤Ж╨╡╤Б╤Б╨╜╤Л╨╣ `threading.RLock` ╨╜╨░ `db_path` (`_local_graph_lock`) **╨Я╨Х╨а╨Х╨Ф** named mutex тЖТ ╨┐╨╛╤В╨╛╨║╨╕ ╤Б╨╡╤А╨╕╨░╨╗╨╕╨╖╤Г╤О╤В╤Б╤П (╨╢╨┤╤Г╤В, ╨╜╨╡ ╨┐╨░╨┤╨░╤О╤В); ╨╝╤Г╤В╨╡╨║╤Б ╤В╨╡╨┐╨╡╤А╤М ╨░╤А╨▒╨╕╤В╤А╨╕╤А╤Г╨╡╤В ╤В╨╛╨╗╤М╨║╨╛ ╨╝╨╡╨╢╨┤╤Г ╨┐╤А╨╛╤Ж╨╡╤Б╤Б╨░╨╝╨╕. +- **Guard:** `tests/test_graph_lock_threadsafe.py` (╨▓╤В╨╛╤А╨╛╨╣ ╨┐╨╛╤В╨╛╨║ ╨╢╨┤╤С╤В ╨╕ acquires ╨┐╨╛╤Б╨╗╨╡ release, `A-out` < `B-in`); 121 graph-related passed; ruff clean. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed (live-╨┐╤А╨╛╨▓╨╡╤А╨║╨░ ╤В╤А╨╡╨▒╤Г╨╡╤В reload MCP тАФ ╨┐╤А╨╛╤Ж╨╡╤Б╤Б ╨╜╨╡╤Б╤С╤В ╤Б╤В╨░╤А╤Л╨╣ `graph.py`). -## 2026-09-18 — PRE-EXISTING: tests/test_lsp_vfs_indexing.py broken (MagicMock.embedding_dim truthy) +## 2026-09-25 тАФ Chain-map: 8 ╨╜╨╡╨╖╨░╤Й╨╕╤Й╤С╨╜╨╜╤Л╤Е ╨╜╨░╤В╨╕╨▓╨╜╤Л╤Е ╨╖╨▓╨╡╨╜╤М╨╡╨▓ ╨╕╨╜╨┤╨╡╨║╤Б╨░╤В╨╛╤А╨░ ╨╖╨░╨║╤А╤Л╤В╤Л `run_bounded` (Fixed) -- **Источник:** попутная находка во время Фазы 1 -- **Описание:** `MagicMock().embedding_dim` truthy → `_target_dim = self.embedder.embedding_dim or 768` (db_writer.py:59) = MagicMock → вектор обрезается до zero → `Zero vector ... skipping` → все чанки пропущены → пустая таблица → 8/8 тестов FAIL. В CI не ловится: `pytestmark = slow`, addopts `-m "not slow"` → никогда не гоняется. -- **Fix:** не внесён (выходит за рамки Фазы 1); мой тест `tests/test_freshness_checker.py` обходит через явный `embedding_dim=1024`. Типовое исправление для lsp_vfs: задать `embedding_dim` в mock. -- **Статус:** 🔬 открыт (P2, низкий приоритет) +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** `docs/research/indexer_chain_map_2026-09-25.md`, `tests/test_reindex_link_bounds.py` +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** ╨║╨░╤А╤В╨░ ╤Ж╨╡╨┐╨╛╤З╨║╨╕ `run()` (╤В╤А╨╕╨│╨│╨╡╤АтЖТ╨║╨╛╨╜╨╡╤Ж) ╨╜╨░╤И╨╗╨░ **8 ╨╖╨▓╨╡╨╜╤М╨╡╨▓ ╤В╨╛╨│╨╛ ╨╢╨╡ ╨║╨╗╨░╤Б╤Б╨░**, ╤З╤В╨╛ `_safe_optimize`: `_verify_and_repair_table_integrity`, known_hashes `to_lance()`, `embed_batch`, `bulk_write`, prune, BM25 `searcher.reindex`, `summarizer.save_cache`, `save_symbol_index`. ╨Ы╤О╨▒╨╛╨╡ ╨╖╨░╨▓╨╕╤Б╨░╨╜╨╕╨╡ ╨╜╨░╤В╨╕╨▓╨╜╨╛╨│╨╛ ╨▓╤Л╨╖╨╛╨▓╨░ = ╨▓╨╡╤З╨╜╨░╤П ╤Д╨░╨╖╨░ (0 CPU, ╨▒╨╡╨╖ ╤Б╨╕╨│╨╜╨░╨╗╨░). +- **Fix:** `_bounded_link(fn, label, fatal=)` (╨╛╨▒╤С╤А╤В╨║╨░ ╨╜╨░╨┤ `run_bounded`, ╤В╨░╨╣╨╝╨░╤Г╤В `MSCODEBASE_LINK_TIMEOUT_SEC`, default 300): non-fatal (verify/known_hashes/prune/BM25/summarizer/symbol) тЖТ skip+log; **fatal** (`embed_batch`/`bulk_write`) тЖТ `RuntimeError` (resume-safe: ╨╕╨╜╨║╤А╨╡╨╝╨╡╨╜╤В╨░╨╗╤М╨╜╤Л╨╡ ╤З╨╡╨║╨┐╨╛╨╣╨╜╤В╤Л). ╨Ю╨▒╤С╤А╨╜╤Г╤В╤Л ╨▓╤Б╨╡ 8. +- **Guard:** `tests/test_reindex_link_bounds.py` (4: value/╨┐╤А╨╛╨┐╤Г╤Б╨║/fatal-raise/propagate) + 18 ╤Б╨▓╤П╨╖╨░╨╜╨╜╤Л╤Е + 78 indexer/search passed; ruff clean. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed. -## 2026-09-11 — Burst-rename: fail-closed VOR отзывает узлы по rename-sweep; 1 ЛОЖНЫЙ отзыв (ADR-7232a6e2ba34) +## 2026-09-25 тАФ IVF finalize hang: timeout-guard ╨╜╨╡ ╨╝╨╛╨╢╨╡╤В ╤Б╤А╨░╨▒╨╛╤В╨░╤В╤М (shutdown(wait=True) join'╨╕╤В ╨╖╨░╨▓╨╕╤Б╤И╨╕╨╣ optimize) (Fixed / Open) -- **Источник:** AGENT_DIARY.md 2026-09-11 + EXPERIMENTS_LOG 1-B/1-C/RT -- **Описание:** VOR (ADR-0003) проверяет path-якоря против HEAD: rename/move = старый путь отсутствует = SILENT_ABSENCE. Real: 24 авто-REFUTED = 13 мусор якорей + 10 настоящих удалений + 1 ЛОЖНЫЙ (ADR-7232a6e2ba34 жив, отозван по старому пути src/utils/paths.py из prose тела). Synthetic 1-C: git mv 30 файлов одним коммитом → 30/30 REFUTED (100%); body-hash → 30/30 уцелели. Red-Team: batch-по-коммиту спасает настоящие удаления (e661861f = D+R083 в одном коммите). -- **Статус:** 🔬 открыт — решение не принято (вопрос владельцу: body-hash carry против стоимости) +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** live job `31f5a9a7` (╨╖╨░╨▓╨╕╤Б ╨╜╨░ ┬лFinalizing 95%┬╗, 0 CPU ╤Г ╨▓╤Б╨╡╤Е ╨┐╤А╨╛╤Ж╨╡╤Б╤Б╨╛╨▓, `.write_lock` ╨╖╨░╨╗╨╛╤З╨╡╨╜); ╤Н╨║╤Б╨┐╨╡╤А╨╕╨╝╨╡╨╜╤В╤Л `experiments/misc_probes/exp_timeout_cancel_mechanism.py` + `exp_ivf_guard_negative_control.py`; `index_project_runner.py:666-755` +- **Root Cause:** `_safe_optimize` ╨╜╨╡ ╨╝╨╛╨╢╨╡╤В ╨╛╨│╤А╨░╨╜╨╕╤З╨╕╤В╤М `table.optimize()`: `Future.result(timeout=)` **╨╜╨╡ ╨╛╤В╨╝╨╡╨╜╤П╨╡╤В** ╨╖╨░╨┐╤Г╤Й╨╡╨╜╨╜╤Л╨╣ ╨┐╨╛╤В╨╛╨║ (╨▓ Python ╨┐╨╛╤В╨╛╨║ ╨╜╨╡╨╗╤М╨╖╤П ╤Г╨▒╨╕╤В╤М), ╨░ `finally: _opt_ex.shutdown(wait=True)` (`:687`) **join'╨╕╤В** ╤В╨╛╤В ╤Б╨░╨╝╤Л╨╣ ╨╖╨░╨▓╨╕╤Б╤И╨╕╨╣ ╨▓╤Л╨╖╨╛╨▓; `wait=False` ╨▓ `except` ╨╜╨╡╨╝╨╡╨┤╨╗╨╡╨╜╨╜╨╛ ╨┐╨╡╤А╨╡╨║╤А╤Л╤В `wait=True` ╨▓ `finally` тЖТ job ╨▓╨╕╤Б╨╕╤В ╨▓╨╡╤З╨╜╨╛. ╨Ч╨░╨╝╨╡╤А (timeout 1╤Б, worker 6╤Б): result ╤Б╤А╨░╨▒╨╛╤В╨░╨╗ ╨╜╨░ 1.01╤Б, `shutdown(wait=True)` ╨╖╨░╨▒╨╗╨╛╨║╨╕╤А╨╛╨▓╨░╨╗ ╨╡╤Й╤С 4.99╤Б (╨╕╤В╨╛╨│╨╛ 6.00╤Б ╨▓╨╝╨╡╤Б╤В╨╛ 1.0╤Б). In-code negative control: `_safe_ivf_index(timeout=1)` ╨┐╤А╨╕ `optimize`=5╤Б ╨▓╨╡╤А╨╜╤Г╨╗╤Б╤П ╨╖╨░ **5.00╤Б** тАФ guard ╨╜╨╡ ╤Б╤А╨░╨▒╨╛╤В╨░╨╗. +- **╨Я╨╛╤З╨╡╨╝╤Г guard ╨╜╨╡ ╨┐╨╛╨╣╨╝╨░╨╗:** ╤Б╤Г╤Й╨╡╤Б╤В╨▓╤Г╤О╤Й╨╕╨╣ `tests/test_reindex_finalizing_deadlock.py::test_safe_ivf_index_create_index_timeout...` ╨┐╨╛╨║╤А╤Л╨▓╨░╨╗ ╨╖╨░╨▓╨╕╤Б╤И╨╕╨╣ **create_index** (╤В╨░╨╝ `finally` = `wait=False`), ╨░ `_SlowTable.optimize` ╨▓╨╛╨╖╨▓╤А╨░╤Й╨░╨╗╤Б╤П ╨╝╨│╨╜╨╛╨▓╨╡╨╜╨╜╨╛ тЖТ ╤Б╨╗╤Г╤З╨░╨╣ optimize ╨╜╨╡ ╤В╨╡╤Б╤В╨╕╤А╨╛╨▓╨░╨╗╤Б╤П (╤Б╨╗╨╡╨┐╨╛╨╡ ╨┐╤П╤В╨╜╨╛ guard'╨░). +- **Fix:** ╨╜╨╛╨▓╤Л╨╣ `_call_with_timeout(fn, timeout, label)` тАФ daemon-thread + `Event.wait(timeout)`, ╨С╨Х╨Ч join; ╨╜╨░ ╤В╨░╨╣╨╝╨░╤Г╤В╨╡ worker abandoned (job ╨╜╨╡ ╨▒╨╗╨╛╨║╨╕╤А╤Г╨╡╤В╤Б╤П), `_safe_optimize` тЖТ False тЖТ create_index ╨╜╨╡ ╤Б╤В╨░╤А╤В╤Г╨╡╤В ╨╜╨░ ╨╜╨╡╨╛╨┐╤А╨╡╨┤╨╡╨╗╤С╨╜╨╜╨╛╨╝ ╤Б╨╛╤Б╤В╨╛╤П╨╜╨╕╨╕. +╤В╨╡╤Б╤В `test_safe_ivf_index_optimize_timeout_does_not_hang` (╨╕ AssertionError, ╨╡╤Б╨╗╨╕ create_index ╨┐╨╛╨╣╨┤╤С╤В ╨┐╨╛╤Б╨╗╨╡ abandon). ╨Я╤А╨╛╨▓╨╡╤А╨║╨░: negative control **5.00╤Б тЖТ 1.01╤Б (PASS)**; 3/3 deadlock-╤Д╨░╨╣╨╗╨░, 12/12 ╤Б╨╝╨╡╨╢╨╜╤Л╨╡ ╤Б IVF; ruff ╤З╨╕╤Б╤В. +- **Red Team (╨╛╤Б╤В╨░╤В╨╛╤З╨╜╨╛╨╡):** ╤Д╨╕╨║╤Б ╤Г╨▒╨╕╤А╨░╨╡╤В **╨╖╨░╨▓╨╕╤Б╨░╨╜╨╕╨╡ job ╨╜╨░╨▓╤Б╨╡╨│╨┤╨░**, ╨╜╨╛ ╨Э╨Х ╤З╨╕╨╜╨╕╤В ╨║╨╛╤А╨╡╨╜╤М ╨╖╨░╨▓╨╕╤Б╨░╨╜╨╕╤П ╨╜╨░╤В╨╕╨▓╨╜╨╛╨│╨╛ `optimize()` (LanceDB/Windows) тАФ abandoned daemon-thread ╨▓╨╕╤Б╨╕╤В ╨┤╨╛ ╤А╨╡╤Б╤В╨░╤А╤В╨░ ╨┐╤А╨╛╤Ж╨╡╤Б╤Б╨░ (╤Г╤В╨╡╤З╨║╨░ ╨┐╤А╨╕ ╨┐╨╛╨▓╤В╨╛╤А╨╜╤Л╤Е ╨╖╨░╨▓╨╕╤Б╨░╨╜╨╕╤П╤Е). Open: ╤А╨╡╤И╨╡╨╜╨╕╨╡ ╨▓╨╗╨░╨┤╨╡╨╗╤М╤Ж╨░ тАФ process-isolated optimize (killable) ╨╗╨╕╨▒╨╛ ╨░╨▓╤В╨╛-disable IVF ╨┐╨╛╤Б╨╗╨╡ N abandons. +- **╨Ъ╨Ы╨Р╨б╨б-╨Р╨г╨Ф╨Ш╨в (`experiments/misc_probes/exp_timeout_class_audit.py`, in-code controls, 2026-09-25):** ╤В╨╛╤В ╨╢╨╡ ╨┤╨╡╤Д╨╡╨║╤В ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╤С╨╜ ╨╡╤Й╤С ╨▓ 4 ╨╝╨╡╤Б╤В╨░╤Е тАФ **(#2)** `agentic_search.py:551-562` `with ThreadPoolExecutor` + `result(timeout=60)` тЖТ `__exit__` ╨┤╨╢╨╛╨╣╨╜╨╕╤В ╨╖╨░╨▓╨╕╤Б╤И╨╕╨╣ ╨▓╨╛╤А╨║╨╡╤А (5.00╤Б ╨▓╨╝╨╡╤Б╤В╨╛ 1╤Б); **(#3)** parse-╤Д╨░╨╖╨░ `index_project_runner.py:360-365` `fut.result()` ╨С╨Х╨Ч ╤В╨░╨╣╨╝╨░╤Г╤В╨░ тЖТ ╨╜╨╡╨╛╨│╤А╨░╨╜╨╕╤З╨╡╨╜; **(#4)** `error_handler.py:649-654` `future.cancel()` ╨╜╨░ running-╨╖╨░╨┤╨░╤З╨╡ тЖТ False (╨╜╨╡ ╨╛╤В╨╝╨╡╨╜╤П╨╡╤В, ╤Г╤В╨╡╤З╨║╨░); **(#5)** ╨▓╨╛╤А╨║╨╡╤А╤Л `ThreadPoolExecutor` non-daemon тЖТ `concurrent.futures` atexit `_python_exit` ╨┤╨╢╨╛╨╣╨╜╨╕╤В тЖТ ╨▒╨╗╨╛╨║ ╨╖╨░╨▓╨╡╤А╤И╨╡╨╜╨╕╤П ╨┐╤А╨╛╤Ж╨╡╤Б╤Б╨░/MCP. ╨Т╤Б╨╡ 4 тАФ **FIXED 2026-09-25** ╤Б╨╕╤Б╤В╨╡╨╝╨╜╤Л╨╝ helper'╨╛╨╝ `src/core/run_bounded.py` (daemon-thread + `Event.wait`, ╨▒╨╡╨╖ join; abandonтЖТdefault). ╨Ч╨░╨╝╨╡╨╜╨╡╨╜╤Л: #2 `agentic_search` (timeout 60), #3 parse-╤Д╨░╨╖╨░ (`MSCODEBASE_PARSE_TIMEOUT_SEC`, default 60), #4 `error_handler` sync-wrapper (╨┐╤Г╨╗ `_SYNC_POOL` ╨▒╨╛╨╗╤М╤И╨╡ ╨╜╨╡ ╨╕╤Б╨┐╨╛╨╗╤М╨╖╤Г╨╡╤В╤Б╤П ╨╜╨░ hot-path), engine `hybrid_search` sync-wrapper (timeout 30). Guard-╨╕╨╜╤Б╤В╤А╤Г╨╝╨╡╨╜╤В╤Л: `tests/test_run_bounded.py` (5), `exp_in_situ_agentic_timeout.py` ╨╕ `exp_in_situ_parse_timeout.py` ╤В╨╡╨┐╨╡╤А╤М ╨░╤Б╤Б╨╡╤А╤В╤П╤В BOUNDED (1.00╤Б / run() ╨▓╨╛╨╖╨▓╤А╨░╤Й╨░╨╡╤В╤Б╤П). ╨Я╤А╨╛╨│╨╛╨╜: run_bounded 5 + finalizing 3 + agentic-search 25 (╨▒╨╡╨╖ addopts) + error/searcher/hybrid/deep 70 passed; ruff clean. **╨Ю╤Б╤В╨░╤В╨╛╤З╨╜╨╛╨╡:** ╨║╨╛╤А╨╡╨╜╤М ╨╖╨░╨▓╨╕╤Б╨░╨╜╨╕╤П ╤Б╨░╨╝╨╛╨│╨╛ ╨╜╨░╤В╨╕╨▓╨╜╨╛╨│╨╛ `optimize()` ╨╜╨╡ ╤Г╤Б╤В╤А╨░╨╜╤С╨╜ (process-isolated ╨▓╨░╤А╨╕╨░╨╜╤В тАФ ╨╛╤В╨┤╨╡╨╗╤М╨╜╨╛╨╡ ╤А╨╡╤И╨╡╨╜╨╕╨╡). +- **LIVE in-situ (A+, 2026-09-25):** (#2) ╤А╨╡╨░╨╗╤М╨╜╤Л╨╣ `agentic_code_search` ╨▓╨╡╤А╨╜╤Г╨╗╤Б╤П ╨╖╨░ **5.01╤Б** ╨┐╤А╨╕ ╨╜╨░╨▒╨╗╤О╨┤╨░╨╡╨╝╨╛╨╝ ╤В╨░╨╣╨╝╨░╤Г╤В╨╡ 1╤Б тАФ `exp_in_situ_agentic_timeout.py`; (#3) ╤А╨╡╨░╨╗╤М╨╜╤Л╨╣ `IndexProjectRunner.run()` **╨╜╨╡ ╨▓╨╡╤А╨╜╤Г╨╗╤Б╤П ╨╖╨░ watchdog 6╤Б** ╨┐╤А╨╕ ╨╖╨░╨▓╨╕╤Б╤И╨╡╨╝ `_parse_file_only` тАФ `exp_in_situ_parse_timeout.py` (╨┐╤А╨╕╨╜╤Г╨┤╨╕╤В╨╡╨╗╤М╨╜╤Л╨╣ `os._exit` ╨┤╨╗╤П ╨╛╨▒╤Е╨╛╨┤╨░ atexit-join тАФ ╨║╨╛╤Б╨▓╨╡╨╜╨╜╨╛ ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨░╨╡╤В ╨╕ #5). (#4) ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╤С╨╜ ╨╜╨░ ╨╢╨╕╨▓╨╛╨╝ `Future.cancel()` (runningтЖТFalse), ╨╜╨╛ ╨╜╨╡ ╤З╨╡╤А╨╡╨╖ ╤А╨╡╨░╨╗╤М╨╜╤Л╨╣ ╨┤╨╡╨║╨╛╤А╨░╤В╨╛╤А; (#5) daemon=False ╨┐╤А╨╛╨▓╨╡╤А╨╡╨╜ ╨▓╨╢╨╕╨▓╤Г╤О. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed (job-╨╖╨░╨▓╨╕╤Б╨░╨╜╨╕╨╡ ╤Г╤Б╤В╤А╨░╨╜╨╡╨╜╨╛ + regression guard) / ЁЯФм Open (╨║╨╛╤А╨╜╨╡╨▓╨╛╨╡ ╨╖╨░╨▓╨╕╤Б╨░╨╜╨╕╨╡ optimize ╨╕ ╤Г╤В╨╡╤З╨║╨░ ╨┐╨╛╤В╨╛╨║╨░) -## 2026-09-02 20:51 — drift_gate заблокировал коммит: контроль остановил самого автора +## 2026-09-25 тАФ ETA/╨┐╤А╨╛╨│╤А╨╡╤Б╤Б ╨┐╨╛╨║╤А╤Л╨▓╨░╨╡╤В ╤В╨╛╨╗╤М╨║╨╛ ╤Д╨░╨╖╤Г ╤Н╨╝╨▒╨╡╨┤╨┤╨╕╨╜╨│╨░; ╨╜╨░╤А╨╡╨╖╨║╨░ ╨╝╨░╤Б╨║╨╕╤А╤Г╨╡╤В╤Б╤П, ╤Е╨▓╨╛╤Б╤В ╨╜╨╡ ╤Б╤З╨╕╤В╨░╨╡╤В╤Б╤П (Open) -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ? Fixed (коммит A 08281f37 приземлился; B — отдельная незакоммиченная квитанция) -**Root Cause:** предсуществующий BROKEN drift_gate: GitBash bin/ (C:\Program Files\Git\bin) НЕ в PATH проце... -- **Статус:** автоматически синхронизировано +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** live-╤А╨░╨╖╨▒╨╛╤А job `31f5a9a7` (full reindex 2026-09-25), `layer.py:1985-2045`, `embed_progress.py:12-58`, `store.py:195-271`, `tools_reg.py:288-359` +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** `job.progress` тАФ ╨▓╨╖╨▓╨╡╤И╨╡╨╜╨╜╨░╤П ╤Д╨░╨╖╨╛╨▓╨░╤П ╤И╨║╨░╨╗╨░ ╤Б ╤А╨░╨╖╨╜╤Л╨╝╨╕ ╨╖╨╜╨░╨╝╨╡╨╜╨░╤В╨╡╨╗╤П╨╝╨╕ ╨╜╨░ ╤Д╨░╨╖╤Г: `parsing/scanning 0.1+ratio*0.4` (10тАУ50%), `embedding 0.5+ratio*0.3` (50тАУ80%), `finalizing 0.8+ratio*0.15` (80тАУ95%), `ratio=files_done/files_total` (`layer.py:761-775`). Embed-╤Д╨░╨╖╨░ ╨╕╨╝╨╡╨╡╤В **╤Б╨╛╨▒╤Б╤В╨▓╨╡╨╜╨╜╤Л╨╣** ╤Б╤З╤С╤В╤З╨╕╨║ ╤З╨░╨╜╨║╨╛╨▓ ╨▓ %, ╤Б ╨┤╤А╤Г╨│╨╕╨╝ ╨╖╨╜╨░╨╝╨╡╨╜╨░╤В╨╡╨╗╨╡╨╝ тЖТ ╨╜╨░ ╤Н╨║╤А╨░╨╜╨╡ ╨╛╨┤╨╜╨╛╨▓╤А╨╡╨╝╨╡╨╜╨╜╨╛ ╨┤╨▓╨░ ╨╜╨╡╤Б╨╛╨┐╨╛╤Б╤В╨░╨▓╨╕╨╝╤Л╤Е ╨┐╤А╨╛╤Ж╨╡╨╜╤В╨░ (live: job 54% ╨┐╤А╨╕ chunks 7% тАФ ╤Н╤В╨╛ 0.5-╨┐╨╛╨╗ ╨┐╨░╤А╤Б╨╕╨╜╨│╨░ + 0.07*0.3, ╤В.╨╡. ╨░╤А╨╕╤Д╨╝╨╡╤В╨╕╨║╨░, ╨╜╨╡ ╨▒╨░╨│). ETA ╤Б╤З╨╕╤В╨░╨╡╤В╤Б╤П **╤В╨╛╨╗╤М╨║╨╛** ╨┤╨╗╤П embed (╨┐╨░╤А╤Б╨╡╤А `[embed] done/total тАж ch/s`, `embed_progress.py`); `finalizing` (LanceDB optimize+IVF) тАФ ╨│╤А╤Г╨▒╤Л╨╣ rolling-average ╨╕╨╖ `job_history.json` (`store.py:249-271`, fallback 120╤Б); write/╨│╤А╨░╤Д/SymbolIndex/auto-doc ╨╜╨╡ ╨╕╨╖╨╝╨╡╤А╤П╤О╤В╤Б╤П ╨▓╨╛╨▓╤Б╨╡. ╨Ш╤В╨╛╨│: total wall-clock ╨┐╤А╨╡╨▓╤Л╤И╨░╨╡╤В ETA (╨┐╤А╨╡╤Ж╨╡╨┤╨╡╨╜╤В exp-13: ETA 18s vs 552s actual). +- **╨в╤А╨╡╨▒╨╛╨▓╨░╨╜╨╕╨╡ ╨▓╨╗╨░╨┤╨╡╨╗╤М╤Ж╨░:** ETA ╨┤╨╛╨╗╨╢╨╡╨╜ ╤Г╤З╨╕╤В╤М╤Б╤П ╨╜╨░ ╨┤╨░╨╜╨╜╤Л╤Е ╨╕ ╨┐╨╛╨║╨░╨╖╤Л╨▓╨░╤В╤М ╨╛╨▒╤Й╨╡╨╡ ╨▓╤А╨╡╨╝╤П, ╨┐╨╛╨║╤А╤Л╨▓╨░╤П ╨▓╤Б╨╡ ╤Д╨░╨╖╤Л (parse тЖТ embed тЖТ write тЖТ finalize тЖТ graph/symbols тЖТ docs). +- **Fix (╨┐╨╗╨░╨╜):** (1) ╨┐╨╡╤А-╤Д╨░╨╖╨╜╤Л╨╡ ╨╖╨░╨┐╨╕╤Б╨╕ ╨▓ `job_history.json` {phase, size(files/chunks), duration}; (2) ╨╝╨╛╨┤╨╡╨╗╤М ╨╜╨░ ╤Д╨░╨╖╤Г (╨╝╨╡╨┤╨╕╨░╨╜╨░/╤А╨╡╨│╤А╨╡╤Б╤Б╨╕╤П ╨┐╨╛ ╤А╨░╨╖╨╝╨╡╤А╤Г) ╨▓╨╝╨╡╤Б╤В╨╛ ╨╛╨┤╨╜╨╛╨│╨╛ ╨╛╨▒╤Й╨╡╨│╨╛ ╤Б╤А╨╡╨┤╨╜╨╡╨│╨╛; (3) total ETA = ╤Б╤Г╨╝╨╝╨░ ╤Д╨░╨╖ ╤Б ╨╕╨╖╨╝╨╡╤А╨╕╨╝╤Л╨╝ ╨┤╤А╨░╨╣╨▓╨╡╤А╨╛╨╝, ╨│╨┤╨╡ ╨┤╤А╨░╨╣╨▓╨╡╤А╨░ ╨╜╨╡╤В тАФ ╤З╨╡╤Б╤В╨╜╤Л╨╣ None / ┬л╤Д╨░╨╖╨░ ╨▒╨╡╨╖ ETA┬╗, ╨╜╨╡ ╤З╨╕╤Б╨╗╨╛; (4) UI: ╤В╨╡╨║╤Г╤Й╨░╤П ╤Д╨░╨╖╨░ ╨╛╤В╨┤╨╡╨╗╤М╨╜╨╛ ╨╛╤В embed-╨▒╨░╤А╨░; (5) negative control: ETA ╨╜╨╡ ╨┐╨╛╨║╨░╨╖╤Л╨▓╨░╤В╤М ╨▒╨╡╨╖ ╨┤╨░╨╜╨╜╤Л╤Е ╤Д╨░╨╖╤Л (guard ╨╛╤В ╤Д╨╡╨╣╨║╨╛╨▓╨╛╨│╨╛ ╤З╨╕╤Б╨╗╨░). +- **╨б╤В╨░╤В╤Г╤Б:** ЁЯФм Open (P1 тАФ ╨╕╤Б╨║╨░╨╢╨░╨╡╤В ╨╛╨╢╨╕╨┤╨░╨╜╨╕╤П ╨┐╨╛ ╨▓╤А╨╡╨╝╨╡╨╜╨╕; ╨╕╨╜╤Ж╨╕╨┤╨╡╨╜╤В exp-13) -## 2026-09-02 21:40 — COMMIT B (head-freshness) приземлился: cb88c961; + cp1251 encoding-инцидент +## 2026-09-22 тАФ TESTS-╤А╤С╨▒╤А╨░ ╤В╤А╨░╨╜╨╖╨╕╤В╨╕╨▓╨╜╤Л, ╨░ ╨╜╨╡ ┬л╤В╨╡╤Б╤В╤Л ╨┐╤А╨╛ ╤Д╤Г╨╜╨║╤Ж╨╕╤О┬╗; E17 LLM-pilot ╤Б╨╗╨╛╨╝╨░╨╜ ╨╜╨░ ╨╕╨╖╨▓╨╗╨╡╤З╨╡╨╜╨╕╨╕ ╨║╨╛╨┤╨░ (Fixed / Open) -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed (коммит B cb88c961; все 5 pre-commit hook'ов OK; рабочее дерево чистое) -**Root Cause 1 (B):** после A (fail-closed symbol, никогда REFUTED) свежесть индекса не проверялась — отсутс... -- **Статус:** автоматически синхронизировано +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md#2026-09-22 (E17 Post-Mortem), `bootstrap_tests.py:216-244` +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** (1) dynamic-trace ╨╗╨╕╨╜╨║╤Г╨╡╤В ╤В╨╡╤Б╤В ╤Б *╨║╨░╨╢╨┤╨╛╨╣ ╨╕╤Б╨┐╨╛╨╗╨╜╨╡╨╜╨╜╨╛╨╣* ╤Д╤Г╨╜╨║╤Ж╨╕╨╡╨╣ тЖТ `_ensure_data_root` ╨╕╨╝╨╡╨╡╤В 234 TESTS-╤А╨╡╨▒╤А╨░ ╨┐╤А╨╕ 0 ╨┐╤А╤П╨╝╤Л╤Е ╨▓╤Л╨╖╨╛╨▓╨╛╨▓ ╨▓ `tests/` (`check_disk_space` тАФ 3). ╨Ф╨╗╤П LLM-╨║╨╛╨╜╤В╨╡╨║╤Б╤В╨░ ╤Б╤Н╨╝╨┐╨╗ ╨╕╨╖ 234 тАФ ╤И╤Г╨╝, ╨┐╨╛╤Н╤В╨╛╨╝╤Г B-╨░╤А╨╝ тЙИ D-╨░╤А╨╝. (2) `e17_pilot_{answers,judge}.py` ╨╕╨╖╨▓╨╗╨╡╨║╨░╨╗╨╕ ╨║╨╛╨┤ ╨╜╨░╨╕╨▓╨╜╤Л╨╝ `f"def {name}"`, ╨░ ╨│╤А╨░╤Д ╤Е╤А╨░╨╜╨╕╤В qualifed-╨╕╨╝╨╡╨╜╨░ (`Class.method`, `Class::test`) тЖТ `# FUNC NOT FOUND` ╨┤╨╗╤П ╨▓╤Б╨╡╤Е ╨╝╨╡╤В╨╛╨┤╨╛╨▓, `C_static` ╨┐╤Г╤Б╤В 30/30. +- **Fix:** AST-╨╕╨╖╨▓╨╗╨╡╤З╨╡╨╜╨╕╨╡ ╨▓ `experiments/bootstrap/e17_extract.py` + 13 ╤В╨╡╤Б╤В╨╛╨▓ (Fixed). ╨д╨╕╨╗╤М╤В╤А TESTS ╨┐╨╛ ╤Б╨┐╨╡╤Ж╨╕╤Д╨╕╤З╨╜╨╛╤Б╤В╨╕ (╨┐╤А╤П╨╝╨╛╨╣ ╨▓╤Л╨╖╨╛╨▓ / ╨╝╨░╨╗╤Л╨╣ coverage-set) ╨╕ ╨┐╨╡╤А╨╡╤Б╨▒╨╛╤А╨║╨░ pilot_data тАФ ╨╜╨╡ ╤Б╨┤╨╡╨╗╨░╨╜╤Л. +- **╨б╤В╨░╤В╤Г╤Б:** Fixed (extraction) / Open (specificity-╤Д╨╕╨╗╤М╤В╤А ╨▒╨╗╨╛╨║╨╕╤А╤Г╨╡╤В ╨▓╨░╨╗╨╕╨┤╨╜╤Л╨╣ E17 LLM-pilot). v3.5.0 retrieval hit@1 ╨╜╨╡ ╨╖╨░╨┤╨╡╤В. -## 2026-09-03 — Fake reindex ETA "~8s" + frozen progress in Finalizing (fixed 32f11662) +## 2026-09-19 тАФ ╨Я╤А╨╛╨┤-╨╕╨╜╤Ж╨╕╨┤╨╡╨╜╤В: ╨╝╨╕╨│╤А╨░╤Ж╨╕╤П ╨║╨╛╨╗╨╛╨╜╨╛╨║ lanceDB ╨╝╨╛╨╗╤З╨░ ╨╜╨╡ ╨▓╤Л╨┐╨╛╨╗╨╜╤П╨╗╨░╤Б╤М + db_writer ╤А╨░╨╖╤А╤Г╤И╨░╨╗ ╨С╨Ф ╨┐╤А╨╕ schema-mismatch (Fixed) -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed (коммит 32f11662; все 5 pre-commit hook'ов OK; полный pytest 1587 passed, 2 pre-existing env_extractor fail) -**Root Cause 1:** `_enrich_job_response` — мёртвая ветка истории (job.project_size никогда не присваивается) + сломанная линейная экстраполяция первых 2с → ложный ETA «~8с». **Fix 1:** единый парсер `_embed_progress_from_log` + реальная скорость из лога (remaining/speed), честный None без данных. -**Root Cause 2:** `_safe_ivf_index` без единого progress-колбэка → бар застывал на 0.8, чанки не росли. **Fix 2:** emission «finalizing» колбэка до/после IVF, отображение 0.8→0.95, честная строка в get_job_status. -- **Статус:** автоматически синхронизировано +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md#2026-09-19 +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** ╨┤╨▓╨░ ╨▒╨░╨│╨░, ╨╜╨░╨╣╨┤╨╡╨╜╨╜╤Л╨╡ ╨┐╤А╨╕ E10-╨╕╤Б╤Б╨╗╨╡╨┤╨╛╨▓╨░╨╜╨╕╨╕ ╨┐╨╛╨╕╤Б╨║╨░ (╨╕╨╜╨┤╨╡╨║╤Б ╤Б╤В╤А╨╛╨╕╨╗╤Б╤П ╨╜╨░ ╤Б╨▓╨╡╨╢╨╡╨╣ ╨С╨Ф, ╨╝╨╕╨│╤А╨░╤Ж╨╕╤П ╨╝╨╛╨╗╤З╨░ ╨╜╨╡ ╤Б╤А╨░╨▒╨░╤В╤Л╨▓╨░╨╗╨░): + 1. ╨б╤В╨░╤А╤Л╨╣ `db_manager` ╨╕╨╝╨┐╨╛╤А╤В╨╕╤А╨╛╨▓╨░╨╗ `_migrate_text_full_inplace` / `_migrate_add_metadata_columns` ╨╕╨╖ `indexer_table.py` ╨║╨░╨║ module-level ╤Д╤Г╨╜╨║╤Ж╨╕╨╕, ╨░ ╤Н╤В╨╛ ╨╝╨╡╤В╨╛╨┤╤Л ╨║╨╗╨░╤Б╤Б╨░ `IndexerTableMixin` (indexer_table.py:17,66,94) тЖТ ImportError тЖТ ╨╝╨╕╨│╤А╨░╤Ж╨╕╤П ╨Э╨Х ╨▓╤Л╨┐╨╛╨╗╨╜╤П╨╗╨░╤Б╤М. + 2. `db_writer.is_table_missing` ╤В╤А╨░╨║╤В╨╛╨▓╨░╨╗ `"in table schema"` (schema-mismatch) ╨║╨░╨║ ┬л╤В╨░╨▒╨╗╨╕╤Ж╨░ ╨╛╤В╤Б╤Г╤В╤Б╤В╨▓╤Г╨╡╤В┬╗ тЖТ ╨Я╨Ю╨Ы╨Э╨л╨Щ rebuild (drop + re-embed ~13 ╨╝╨╕╨╜) ╨▓╨╝╨╡╤Б╤В╨╛ soft-╨╝╨╕╨│╤А╨░╤Ж╨╕╨╕. +- **Fix:** `db_manager` тАФ ╨╗╨╛╨║╨░╨╗╤М╨╜╤Л╨╡ `_migrate_text_full_inplace(table)` / `_migrate_add_metadata_columns(existing_fields, table)` ╤Б `pa.field(name, field.type)` ╨╕╨╖ `self.schema`; `db_writer` тАФ `is_table_missing` ╨╕╤Б╨║╨╗╤О╤З╨░╨╡╤В `"in table schema"` (recreate ╤В╨╛╨╗╤М╨║╨╛ ╨┐╤А╨╕ ╤А╨╡╨░╨╗╤М╨╜╨╛╨╝ ╨╛╤В╤Б╤Г╤В╤Б╤В╨▓╨╕╨╕ ╤В╨░╨▒╨╗╨╕╤Ж╤Л). +200 ╤Б╤В╤А╨╛╨║ ╤В╨╡╤Б╤В╨╛╨▓ (`tests/test_lancedb_recreate.py`): ╨╝╨╕╨│╤А╨░╤Ж╨╕╤П legacyтЖТfile_mtime_ns/file_size, ╨╕╨┤╨╡╨╝╨┐╨╛╤В╨╡╨╜╤В╨╜╨╛╤Б╤В╤М, ┬л╨Э╨Х ╨┐╨╡╤А╨╡╤Б╨╛╨╖╨┤╨░╨▓╨░╤В╤М ╨┐╤А╨╕ schema-mismatch┬╗. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed (╨┐╨╛╨┤╨│╨╛╤В╨╛╨▓╨╗╨╡╨╜ ╨║ PR ╨▓ ╤Н╤В╨╛╨╝ ╨║╨╛╨╝╨╝╨╕╤В╨╡). ╨в╨╡╤Б╤В╤Л: test_lancedb_recreate 12 passed, ╤Д╨╛╨║╤Г╤Б-╨│╤А╤Г╨┐╨┐╨░ 43 passed. -## 2026-09-03 19:30 — CI RED: circular import layer ↔ tools_reg (fixed f210ed7c) +## 2026-09-19 тАФ E10 (search quality): full-text-╤Н╨╝╨▒╨╡╨┤╨┤╨╕╨╜╨│ + e5-╨┐╤А╨╡╤Д╨╕╨║╤Б╤Л + ╨┐╤Г╨╗ reranker 50 тЖТ REFUTED (N=10) -- **Источник:** AGENT_DIARY.md#2026-09-03-1930 -- **Описание:** My ETA refactor added `tools_reg → layer` import for `_embed_progress_from_log`, closing existing `layer → tools_reg` cycle. `architecture_linter.py` caught it as `[CIRCULAR]`. Fix: extracted parser into neutral `src/core/intelligence/embed_progress.py`. CI run 33796959353 all-jobs green (ubuntu+windows). -- **Статус:** ✅ Fixed +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** EXPERIMENTS_LOG.md#2026-09-19 +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** ╤В╤А╨╕ ┬л╨▓╤Л╨║╨╗╤О╤З╨░╤В╨╡╨╗╤П┬╗ ╨║╨░╤З╨╡╤Б╤В╨▓╨░ (E10a full-text ╤З╨░╨╜╨║╨░ ╨▓ ╤Н╨╝╨▒╨╡╨┤╨┤╨╕╨╜╨│, e5 `query:`/`passage:`-╨┐╤А╨╡╤Д╨╕╨║╤Б╤Л ╨▓ llama.cpp-╨▓╨╡╤В╨║╨╡ тАФ ONNX/OpenVINO ╤Г╨╢╨╡ ╨╕╨╝╨╡╨╗╨╕ `_ensure_prefix`, E10c ╨┐╤Г╨╗ reranker 30тЖТ50) ╨╜╨╡ ╨┤╨░╨╗╨╕ ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨░╨╡╨╝╨╛╨│╨╛ ╤Б╨┤╨▓╨╕╨│╨░. ╨з╨╕╤Б╤В╤Л╨╣ ╨┐╤А╨╛╨│╨╛╨╜ (599 ╤Д╨░╨╣╨╗╨╛╨▓ / 9514 ╤З╨░╨╜╨║╨╛╨▓, 799.9s): fast hit@1=0% hit@5=50%; quality hit@1=20% hit@5=40%; baseline ╨░╨▓╤В╨╛╤А╨░ 0/50% ╨╕ 30/30%. ╨Ф╨╡╨╗╤М╤В╨░ тАФ ╨▓ ╨┐╤А╨╡╨┤╨╡╨╗╨░╤Е ╤И╤Г╨╝╨░ N=10. +- **Fix (╨┐╤А╨╡╨┤╨╛╤В╨▓╤А╨░╤Й╨╡╨╜╨╕╨╡):** ╨╕╨╖╨╝╨╡╨╜╤С╨╜╨╜╤Л╨╣ ╨║╨╛╨┤ ╨╛╤В╨║╨░╨╗╨╡╨╜ ╨║ HEAD (╨┐╨╛╨▓╨╡╨┤╨╡╨╜╨╕╨╡ ╨║╨╗╨╕╨╡╨╜╤В╨░ = ╨┐╤А╨╛╨┤); ╨╛╤Б╤В╨░╤В╨╛╨║ тАФ env-╤В╤Г╨╝╨▒╨╗╨╡╤А `MAX_RERANKER_INPUT` ╤Б default=30 (╨╜╨╡╨╣╤В╤А╨░╨╗╨╡╨╜). ╨Я╨╗╨░╤Вo ┬лpure-vector┬╗ ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨╡╨╜╨╛ ╨┐╨╛╨▓╤В╨╛╤А╨╜╨╛ (╤Б╤А. Exp-29 ceiling ~0.23). +- **╨б╤В╨░╤В╤Г╤Б:** тЭМ REFUTED (╨╖╨░╨║╤А╤Л╤В, ╨╖╨░╨┐╨╕╤Б╨░╨╜ ╨▓ lab exp-43). ╨б╨╗╨╡╨┤╤Г╤О╤Й╨╕╨╣ ╤Е╨╛╨┤ тАФ AST/Graph-hybrid re-ranking, ╨╜╨╡ ╤Н╨╝╨▒╨╡╨┤╨┤╨╕╨╜╨│╨╛╨▓╤Л╨╡ ╤В╨▓╨╕╨║╨╕. -## 2026-09-04 — CI RED: ruff lint errors caught only after push (fixed 986c9be7) +## 2026-09-18 тАФ ╨д╨░╨╖╨░ 1: Incremental Hot-Reload тАФ FreshnessChecker ╨╛╨╢╨╕╨▓╨╗╤С╨╜, hot-reload ╨╖╨░╤И╨╕╤В (AST+FTS5+╨│╤А╨░╤Д) -- **Источник:** INC-A35A, CI runs 33847347263/33847972948 -- **Описание:** Pre-commit hook did not run ruff, so lint errors (F401, W292) passed locally but failed CI. Repeated 3 times across commits (5a771789, b121ab19, 3dd79ba2). +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md#2026-09-18 +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** FreshnessChecker ╨▒╤Л╨╗ ╨╝╤С╤А╤В╨▓ (0 ╨▓╤Л╨╖╨╛╨▓╨╛╨▓) ╨╕ ╤Б╨╗╨╛╨╝╨░╨╜: `to_pandas(columns=)` ╨┐╨░╨┤╨░╨╡╤В ╨╜╨░ lancedb 0.34 (╤А╨░╨▒╨╛╤З╨╡╨╡ тАФ `to_lance().to_pandas`, 102.2ms/10366 ╨╜╨░╨┐╤А.); ╨┐╤А╨╛╨┐╤Г╤Б╨║╨░╨╗ ╨Э╨Ю╨Т╨л╨Х ╤Д╨░╨╣╨╗╤Л (KI-109 тАФ ╤Д╨░╨╣╨╗ ╨▒╨╡╨╖ notify_change ╨╜╨╡ ╨┐╨╛╨┐╨░╨┤╨░╨╗ ╨▓ ╨╕╨╜╨┤╨╡╨║╤Б); ╨┐╨╡╤А╨╡╨┤╨░╨▓╨░╨╗ `project_path` ╨▓╨╝╨╡╤Б╤В╨╛ `rel` ╨▓ `_index_single_file`. ╨е╨╛╤В-reload ╤Б╤Г╤Й╨╡╤Б╤В╨▓╨╛╨▓╨░╨╗, ╨╜╨╛ ╨╛╤Б╤В╨░╨▓╨╗╤П╨╗ 3 ╨┤╤Л╤А╤Л: FTS5 (incremental_update_fts5 ╤В╨╛╨╗╤М╨║╨╛ ╨┤╨╛╨▒╨░╨▓╨╗╤П╨╡╤В), PropertyGraph (remove_file ╨╜╨╡ ╨▓╤Л╨╖╤Л╨▓╨░╨╗╤Б╤П), schema (╨╜╨╡ ╨▒╤Л╨╗╨╛ mtime/size ╨┤╨╗╤П stat-first). +- **Fix:** schema + `file_mtime_ns`/`file_size` (db_manager/indexer_table/db_writer/index_pipeline/indexer); FreshnessChecker.verify ╨┐╨╡╤А╨╡╨┐╨╕╤Б╨░╨╜: stat-first (mtime+size тЖТ skip ╨▒╨╡╨╖ hash), hash-╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨╡╨╜╨╕╨╡ ╨┤╨╗╤П ╨╜╨╡╤Б╨╛╨▓╨┐╨░╨┤╨╡╨╜╨╕╨╣/legacy, ╨╜╨╛╨▓╤Л╨╡ ╤Д╨░╨╣╨╗╤Л ╨╕╨╜╨┤╨╡╨║╤Б╨╕╤А╤Г╤О╤В╤Б╤П, debounce (`FRESHNESS_INTERVAL_SEC`, 0=╨▓╤Л╨║╨╗) + Lock + `is_reindexing`-╨│╨╡╨╣╤В, ╤Д╨╕╨╗╤М╤В╤А╨░╤Ж╨╕╤П ╤З╨╡╤А╨╡╨╖ real FileGuard; `_index_single_file`: +remove_file (╨│╤А╨░╤Д) +remove_from_fts5 (FTS5) ╨┐╨╡╤А╨╡╨┤ ╨┐╨╡╤А╨╡╨╕╨╜╨┤╨╡╨║╤Б╨░╤Ж╨╕╨╡╨╣; search-╤Е╤Г╨║ `_maybe_hot_reload` (await `asyncio.to_thread`). ╨Я╨╛╨╗╨╜╤Л╨╣ reindex ╨╛╤Б╤В╨░╤С╤В╤Б╤П fallback (╤Б╤А╨░╨▒╨░╤В╤Л╨▓╨░╨╡╤В ╨┐╤А╨╕ ╨┐╤Г╤Б╤В╨╛╨╝ ╨╕╨╜╨┤╨╡╨║╤Б╨╡). 6 ╨╜╨╛╨▓╤Л╤Е ╤В╨╡╤Б╤В╨╛╨▓ + 233 ╤А╨╡╨│╤А╨╡╤Б╤Б-╨┐╤А╨╛╤Е╨╛╨┤╨░. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed ╨╗╨╛╨║╨░╨╗╤М╨╜╨╛ (╨▓╨╡╤В╨║╨░ ╨╜╨╡ ╨╖╨░╨┐╤Г╤И╨╡╨╜╨░, verified_from_clean_state ╨╜╨╡ ╨┐╤А╨╛╨│╨╛╨╜╤П╨╗╤Б╤П). 7 ╨╜╨╛╨▓╤Л╤Е ╤В╨╡╤Б╤В╨╛╨▓ (╨▓╨║╨╗╤О╤З╨░╤П concurrency-╤Б╤В╤А╨╡╤Б╤Б N=16) + 1748 ╨┐╨╛╨╗╨╜╤Л╨╣ pytest green. ╨Я╨╛╨╗╨╜╤Л╨╣ reindex-╨╖╨░╨┐╤Г╤Б╨║ ╨┐╨╛╤Б╨╗╨╡ ╨╝╨╕╨│╤А╨░╤Ж╨╕╨╕ ╤Б╤Г╤Й╨╡╤Б╤В╨▓╤Г╤О╤Й╨╕╤Е ╨С╨Ф ╨╜╨╡ ╨▓╤Л╨┐╨╛╨╗╨╜╤П╨╗╤Б╤П тАФ ╨╖╨░╨┐╤А╨╛╤Б ╨▓╨╗╨░╨┤╨╡╨╗╤М╤Ж╤Г ╨╜╨░ live-check. + +## 2026-09-18 тАФ PRE-EXISTING: tests/test_lsp_vfs_indexing.py broken (MagicMock.embedding_dim truthy) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** ╨┐╨╛╨┐╤Г╤В╨╜╨░╤П ╨╜╨░╤Е╨╛╨┤╨║╨░ ╨▓╨╛ ╨▓╤А╨╡╨╝╤П ╨д╨░╨╖╤Л 1 +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** `MagicMock().embedding_dim` truthy тЖТ `_target_dim = self.embedder.embedding_dim or 768` (db_writer.py:59) = MagicMock тЖТ ╨▓╨╡╨║╤В╨╛╤А ╨╛╨▒╤А╨╡╨╖╨░╨╡╤В╤Б╤П ╨┤╨╛ zero тЖТ `Zero vector ... skipping` тЖТ ╨▓╤Б╨╡ ╤З╨░╨╜╨║╨╕ ╨┐╤А╨╛╨┐╤Г╤Й╨╡╨╜╤Л тЖТ ╨┐╤Г╤Б╤В╨░╤П ╤В╨░╨▒╨╗╨╕╤Ж╨░ тЖТ 8/8 ╤В╨╡╤Б╤В╨╛╨▓ FAIL. ╨Т CI ╨╜╨╡ ╨╗╨╛╨▓╨╕╤В╤Б╤П: `pytestmark = slow`, addopts `-m "not slow"` тЖТ ╨╜╨╕╨║╨╛╨│╨┤╨░ ╨╜╨╡ ╨│╨╛╨╜╤П╨╡╤В╤Б╤П. +- **Fix:** ╨╜╨╡ ╨▓╨╜╨╡╤Б╤С╨╜ (╨▓╤Л╤Е╨╛╨┤╨╕╤В ╨╖╨░ ╤А╨░╨╝╨║╨╕ ╨д╨░╨╖╤Л 1); ╨╝╨╛╨╣ ╤В╨╡╤Б╤В `tests/test_freshness_checker.py` ╨╛╨▒╤Е╨╛╨┤╨╕╤В ╤З╨╡╤А╨╡╨╖ ╤П╨▓╨╜╤Л╨╣ `embedding_dim=1024`. ╨в╨╕╨┐╨╛╨▓╨╛╨╡ ╨╕╤Б╨┐╤А╨░╨▓╨╗╨╡╨╜╨╕╨╡ ╨┤╨╗╤П lsp_vfs: ╨╖╨░╨┤╨░╤В╤М `embedding_dim` ╨▓ mock. +- **╨б╤В╨░╤В╤Г╤Б:** ЁЯФм ╨╛╤В╨║╤А╤Л╤В (P2, ╨╜╨╕╨╖╨║╨╕╨╣ ╨┐╤А╨╕╨╛╤А╨╕╤В╨╡╤В) + +## 2026-09-11 тАФ Burst-rename: fail-closed VOR ╨╛╤В╨╖╤Л╨▓╨░╨╡╤В ╤Г╨╖╨╗╤Л ╨┐╨╛ rename-sweep; 1 ╨Ы╨Ю╨Ц╨Э╨л╨Щ ╨╛╤В╨╖╤Л╨▓ (ADR-7232a6e2ba34) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md 2026-09-11 + EXPERIMENTS_LOG 1-B/1-C/RT +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** VOR (ADR-0003) ╨┐╤А╨╛╨▓╨╡╤А╤П╨╡╤В path-╤П╨║╨╛╤А╤П ╨┐╤А╨╛╤В╨╕╨▓ HEAD: rename/move = ╤Б╤В╨░╤А╤Л╨╣ ╨┐╤Г╤В╤М ╨╛╤В╤Б╤Г╤В╤Б╤В╨▓╤Г╨╡╤В = SILENT_ABSENCE. Real: 24 ╨░╨▓╤В╨╛-REFUTED = 13 ╨╝╤Г╤Б╨╛╤А ╤П╨║╨╛╤А╨╡╨╣ + 10 ╨╜╨░╤Б╤В╨╛╤П╤Й╨╕╤Е ╤Г╨┤╨░╨╗╨╡╨╜╨╕╨╣ + 1 ╨Ы╨Ю╨Ц╨Э╨л╨Щ (ADR-7232a6e2ba34 ╨╢╨╕╨▓, ╨╛╤В╨╛╨╖╨▓╨░╨╜ ╨┐╨╛ ╤Б╤В╨░╤А╨╛╨╝╤Г ╨┐╤Г╤В╨╕ src/utils/paths.py ╨╕╨╖ prose ╤В╨╡╨╗╨░). Synthetic 1-C: git mv 30 ╤Д╨░╨╣╨╗╨╛╨▓ ╨╛╨┤╨╜╨╕╨╝ ╨║╨╛╨╝╨╝╨╕╤В╨╛╨╝ тЖТ 30/30 REFUTED (100%); body-hash тЖТ 30/30 ╤Г╤Ж╨╡╨╗╨╡╨╗╨╕. Red-Team: batch-╨┐╨╛-╨║╨╛╨╝╨╝╨╕╤В╤Г ╤Б╨┐╨░╤Б╨░╨╡╤В ╨╜╨░╤Б╤В╨╛╤П╤Й╨╕╨╡ ╤Г╨┤╨░╨╗╨╡╨╜╨╕╤П (e661861f = D+R083 ╨▓ ╨╛╨┤╨╜╨╛╨╝ ╨║╨╛╨╝╨╝╨╕╤В╨╡). +- **╨б╤В╨░╤В╤Г╤Б:** ЁЯФм ╨╛╤В╨║╤А╤Л╤В тАФ ╤А╨╡╤И╨╡╨╜╨╕╨╡ ╨╜╨╡ ╨┐╤А╨╕╨╜╤П╤В╨╛ (╨▓╨╛╨┐╤А╨╛╤Б ╨▓╨╗╨░╨┤╨╡╨╗╤М╤Ж╤Г: body-hash carry ╨┐╤А╨╛╤В╨╕╨▓ ╤Б╤В╨╛╨╕╨╝╨╛╤Б╤В╨╕) + +## 2026-09-02 20:51 тАФ drift_gate ╨╖╨░╨▒╨╗╨╛╨║╨╕╤А╨╛╨▓╨░╨╗ ╨║╨╛╨╝╨╝╨╕╤В: ╨║╨╛╨╜╤В╤А╨╛╨╗╤М ╨╛╤Б╤В╨░╨╜╨╛╨▓╨╕╨╗ ╤Б╨░╨╝╨╛╨│╨╛ ╨░╨▓╤В╨╛╤А╨░ + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** ? Fixed (╨║╨╛╨╝╨╝╨╕╤В A 08281f37 ╨┐╤А╨╕╨╖╨╡╨╝╨╗╨╕╨╗╤Б╤П; B тАФ ╨╛╤В╨┤╨╡╨╗╤М╨╜╨░╤П ╨╜╨╡╨╖╨░╨║╨╛╨╝╨╝╨╕╤З╨╡╨╜╨╜╨░╤П ╨║╨▓╨╕╤В╨░╨╜╤Ж╨╕╤П) +**Root Cause:** ╨┐╤А╨╡╨┤╤Б╤Г╤Й╨╡╤Б╤В╨▓╤Г╤О╤Й╨╕╨╣ BROKEN drift_gate: GitBash bin/ (C:\Program Files\Git\bin) ╨Э╨Х ╨▓ PATH ╨┐╤А╨╛╤Ж╨╡... +- **╨б╤В╨░╤В╤Г╤Б:** ╨░╨▓╤В╨╛╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╕ ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╕╨╖╨╕╤А╨╛╨▓╨░╨╜╨╛ + +## 2026-09-02 21:40 тАФ COMMIT B (head-freshness) ╨┐╤А╨╕╨╖╨╡╨╝╨╗╨╕╨╗╤Б╤П: cb88c961; + cp1251 encoding-╨╕╨╜╤Ж╨╕╨┤╨╡╨╜╤В + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** тЬЕ Fixed (╨║╨╛╨╝╨╝╨╕╤В B cb88c961; ╨▓╤Б╨╡ 5 pre-commit hook'╨╛╨▓ OK; ╤А╨░╨▒╨╛╤З╨╡╨╡ ╨┤╨╡╤А╨╡╨▓╨╛ ╤З╨╕╤Б╤В╨╛╨╡) +**Root Cause 1 (B):** ╨┐╨╛╤Б╨╗╨╡ A (fail-closed symbol, ╨╜╨╕╨║╨╛╨│╨┤╨░ REFUTED) ╤Б╨▓╨╡╨╢╨╡╤Б╤В╤М ╨╕╨╜╨┤╨╡╨║╤Б╨░ ╨╜╨╡ ╨┐╤А╨╛╨▓╨╡╤А╤П╨╗╨░╤Б╤М тАФ ╨╛╤В╤Б╤Г╤В╤Б... +- **╨б╤В╨░╤В╤Г╤Б:** ╨░╨▓╤В╨╛╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╕ ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╕╨╖╨╕╤А╨╛╨▓╨░╨╜╨╛ + +## 2026-09-03 тАФ Fake reindex ETA "~8s" + frozen progress in Finalizing (fixed 32f11662) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** тЬЕ Fixed (╨║╨╛╨╝╨╝╨╕╤В 32f11662; ╨▓╤Б╨╡ 5 pre-commit hook'╨╛╨▓ OK; ╨┐╨╛╨╗╨╜╤Л╨╣ pytest 1587 passed, 2 pre-existing env_extractor fail) +**Root Cause 1:** `_enrich_job_response` тАФ ╨╝╤С╤А╤В╨▓╨░╤П ╨▓╨╡╤В╨║╨░ ╨╕╤Б╤В╨╛╤А╨╕╨╕ (job.project_size ╨╜╨╕╨║╨╛╨│╨┤╨░ ╨╜╨╡ ╨┐╤А╨╕╤Б╨▓╨░╨╕╨▓╨░╨╡╤В╤Б╤П) + ╤Б╨╗╨╛╨╝╨░╨╜╨╜╨░╤П ╨╗╨╕╨╜╨╡╨╣╨╜╨░╤П ╤Н╨║╤Б╤В╤А╨░╨┐╨╛╨╗╤П╤Ж╨╕╤П ╨┐╨╡╤А╨▓╤Л╤Е 2╤Б тЖТ ╨╗╨╛╨╢╨╜╤Л╨╣ ETA ┬л~8╤Б┬╗. **Fix 1:** ╨╡╨┤╨╕╨╜╤Л╨╣ ╨┐╨░╤А╤Б╨╡╤А `_embed_progress_from_log` + ╤А╨╡╨░╨╗╤М╨╜╨░╤П ╤Б╨║╨╛╤А╨╛╤Б╤В╤М ╨╕╨╖ ╨╗╨╛╨│╨░ (remaining/speed), ╤З╨╡╤Б╤В╨╜╤Л╨╣ None ╨▒╨╡╨╖ ╨┤╨░╨╜╨╜╤Л╤Е. +**Root Cause 2:** `_safe_ivf_index` ╨▒╨╡╨╖ ╨╡╨┤╨╕╨╜╨╛╨│╨╛ progress-╨║╨╛╨╗╨▒╤Н╨║╨░ тЖТ ╨▒╨░╤А ╨╖╨░╤Б╤В╤Л╨▓╨░╨╗ ╨╜╨░ 0.8, ╤З╨░╨╜╨║╨╕ ╨╜╨╡ ╤А╨╛╤Б╨╗╨╕. **Fix 2:** emission ┬лfinalizing┬╗ ╨║╨╛╨╗╨▒╤Н╨║╨░ ╨┤╨╛/╨┐╨╛╤Б╨╗╨╡ IVF, ╨╛╤В╨╛╨▒╤А╨░╨╢╨╡╨╜╨╕╨╡ 0.8тЖТ0.95, ╤З╨╡╤Б╤В╨╜╨░╤П ╤Б╤В╤А╨╛╨║╨░ ╨▓ get_job_status. +- **╨б╤В╨░╤В╤Г╤Б:** ╨░╨▓╤В╨╛╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╕ ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╕╨╖╨╕╤А╨╛╨▓╨░╨╜╨╛ + +## 2026-09-03 19:30 тАФ CI RED: circular import layer тЖФ tools_reg (fixed f210ed7c) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md#2026-09-03-1930 +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** My ETA refactor added `tools_reg тЖТ layer` import for `_embed_progress_from_log`, closing existing `layer тЖТ tools_reg` cycle. `architecture_linter.py` caught it as `[CIRCULAR]`. Fix: extracted parser into neutral `src/core/intelligence/embed_progress.py`. CI run 33796959353 all-jobs green (ubuntu+windows). +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed + +## 2026-09-04 тАФ CI RED: ruff lint errors caught only after push (fixed 986c9be7) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** INC-A35A, CI runs 33847347263/33847972948 +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** Pre-commit hook did not run ruff, so lint errors (F401, W292) passed locally but failed CI. Repeated 3 times across commits (5a771789, b121ab19, 3dd79ba2). - **Fix:** Added `scripts/ruff_gate.py` (step 9 in PRE_COMMIT_HOOK template, git_hooks_installer.py). Also fixed stray `\"\"\"` in template introduced by bb05d9af that caused SyntaxError in generated hook. -- **Статус:** ✅ Fixed +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed -## 2026-09-04 — PRE-EXISTING: hook template SyntaxError (bb05d9af) +## 2026-09-04 тАФ PRE-EXISTING: hook template SyntaxError (bb05d9af) -- **Описание:** Commit bb05d9af added `\"\"\"` (stray triple-quote) after step 8 in PRE_COMMIT_HOOK docstring, creating double `\"\"\"` in generated hook (line 17-18). Hook never compiled — was installed via MCP after commits pushed, so never caught. +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** Commit bb05d9af added `\"\"\"` (stray triple-quote) after step 8 in PRE_COMMIT_HOOK docstring, creating double `\"\"\"` in generated hook (line 17-18). Hook never compiled тАФ was installed via MCP after commits pushed, so never caught. - **Fix:** Removed stray `\"\"\"` in same commit 986c9be7. -- **Статус:** ✅ Fixed +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed -## 2026-09-05 — Process leak: hung git cat-file leaks git+git.exe+conhost chains (RAM 81%, ~200 procs) +## 2026-09-05 тАФ Process leak: hung git cat-file leaks git+git.exe+conhost chains (RAM 81%, ~200 procs) -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed (code only, не запушено) — verify_diary.py + git_hooks_installer.py -**Root Cause:** `check_commit_exists` (verify_diary.py:361): `proc.communicate(timeout=30)` на таймауте НЕ убивает процесс, `except: pass` глотает TimeoutExpired → Popen утекает навсегда. Git for Windows re-exec (git → git.exe) теряет DETACHED_PROCESS → каждый зависший `cat-file` = 3 вечных процесса (git + git.exe + conhost); стартовая Contradiction Ledger-проверка при CPU/Defender contention. -**Fix:** `_kill_git_tree()` (`taskkill /F /T /PID`) на TimeoutExpired в check_commit_exists + то же в run_script (git_hooks_installer.py:93). Снято на живой цепочке 9660→24156→24428. Тесты: 9 passed (5 commit_guard + 2 subprocess_windows + 2 ledger slow); ruff clean по новым строкам. -- **Статус:** ✅ Fixed +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** тЬЕ Fixed (code only, ╨╜╨╡ ╨╖╨░╨┐╤Г╤И╨╡╨╜╨╛) тАФ verify_diary.py + git_hooks_installer.py +**Root Cause:** `check_commit_exists` (verify_diary.py:361): `proc.communicate(timeout=30)` ╨╜╨░ ╤В╨░╨╣╨╝╨░╤Г╤В╨╡ ╨Э╨Х ╤Г╨▒╨╕╨▓╨░╨╡╤В ╨┐╤А╨╛╤Ж╨╡╤Б╤Б, `except: pass` ╨│╨╗╨╛╤В╨░╨╡╤В TimeoutExpired тЖТ Popen ╤Г╤В╨╡╨║╨░╨╡╤В ╨╜╨░╨▓╤Б╨╡╨│╨┤╨░. Git for Windows re-exec (git тЖТ git.exe) ╤В╨╡╤А╤П╨╡╤В DETACHED_PROCESS тЖТ ╨║╨░╨╢╨┤╤Л╨╣ ╨╖╨░╨▓╨╕╤Б╤И╨╕╨╣ `cat-file` = 3 ╨▓╨╡╤З╨╜╤Л╤Е ╨┐╤А╨╛╤Ж╨╡╤Б╤Б╨░ (git + git.exe + conhost); ╤Б╤В╨░╤А╤В╨╛╨▓╨░╤П Contradiction Ledger-╨┐╤А╨╛╨▓╨╡╤А╨║╨░ ╨┐╤А╨╕ CPU/Defender contention. +**Fix:** `_kill_git_tree()` (`taskkill /F /T /PID`) ╨╜╨░ TimeoutExpired ╨▓ check_commit_exists + ╤В╨╛ ╨╢╨╡ ╨▓ run_script (git_hooks_installer.py:93). ╨б╨╜╤П╤В╨╛ ╨╜╨░ ╨╢╨╕╨▓╨╛╨╣ ╤Ж╨╡╨┐╨╛╤З╨║╨╡ 9660тЖТ24156тЖТ24428. ╨в╨╡╤Б╤В╤Л: 9 passed (5 commit_guard + 2 subprocess_windows + 2 ledger slow); ruff clean ╨┐╨╛ ╨╜╨╛╨▓╤Л╨╝ ╤Б╤В╤А╨╛╨║╨░╨╝. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed -## 2026-09-05 — stale_detector + predict_change стабильно -32001 через MCP (fixed code only) +## 2026-09-05 тАФ stale_detector + predict_change ╤Б╤В╨░╨▒╨╕╨╗╤М╨╜╨╛ -32001 ╤З╨╡╤А╨╡╨╖ MCP (fixed code only) -- **Источник:** AGENT_DIARY.md#2026-09-05-1230 -- **Описание:** `error_boundary` применяет `asyncio.wait_for(timeout_ms)`, но внутри `execute` вызывается синхронный блокирующий код (`stale_run` 10-29s, `static_predict` git-subprocess). На Windows wait_for НЕ может отменить работающий синхронный блок → event loop заблокирован, клиент отваливается по -32001 до ответа. Эксперимент: wait_for(10s) вокруг sync stale_run НЕ прервал (24.7s); `asyncio.to_thread` + wait_for(5s) → реальный таймаут, loop жив. -- **Fix:** оба инструмента обёрнуты в `asyncio.to_thread` (doc_tools._scan_docs, predict_tools.static_predict/ChangePreview.run); таймауты 10s→60s (stale), 60s→120s (predict). Прямые вызовы: stale OK 13.0s, predict OK 1.4s; 62 теста passed. -- **Статус:** ✅ Fixed (code only, MCP reload требуется) +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md#2026-09-05-1230 +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** `error_boundary` ╨┐╤А╨╕╨╝╨╡╨╜╤П╨╡╤В `asyncio.wait_for(timeout_ms)`, ╨╜╨╛ ╨▓╨╜╤Г╤В╤А╨╕ `execute` ╨▓╤Л╨╖╤Л╨▓╨░╨╡╤В╤Б╤П ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╜╤Л╨╣ ╨▒╨╗╨╛╨║╨╕╤А╤Г╤О╤Й╨╕╨╣ ╨║╨╛╨┤ (`stale_run` 10-29s, `static_predict` git-subprocess). ╨Э╨░ Windows wait_for ╨Э╨Х ╨╝╨╛╨╢╨╡╤В ╨╛╤В╨╝╨╡╨╜╨╕╤В╤М ╤А╨░╨▒╨╛╤В╨░╤О╤Й╨╕╨╣ ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╜╤Л╨╣ ╨▒╨╗╨╛╨║ тЖТ event loop ╨╖╨░╨▒╨╗╨╛╨║╨╕╤А╨╛╨▓╨░╨╜, ╨║╨╗╨╕╨╡╨╜╤В ╨╛╤В╨▓╨░╨╗╨╕╨▓╨░╨╡╤В╤Б╤П ╨┐╨╛ -32001 ╨┤╨╛ ╨╛╤В╨▓╨╡╤В╨░. ╨н╨║╤Б╨┐╨╡╤А╨╕╨╝╨╡╨╜╤В: wait_for(10s) ╨▓╨╛╨║╤А╤Г╨│ sync stale_run ╨Э╨Х ╨┐╤А╨╡╤А╨▓╨░╨╗ (24.7s); `asyncio.to_thread` + wait_for(5s) тЖТ ╤А╨╡╨░╨╗╤М╨╜╤Л╨╣ ╤В╨░╨╣╨╝╨░╤Г╤В, loop ╨╢╨╕╨▓. +- **Fix:** ╨╛╨▒╨░ ╨╕╨╜╤Б╤В╤А╤Г╨╝╨╡╨╜╤В╨░ ╨╛╨▒╤С╤А╨╜╤Г╤В╤Л ╨▓ `asyncio.to_thread` (doc_tools._scan_docs, predict_tools.static_predict/ChangePreview.run); ╤В╨░╨╣╨╝╨░╤Г╤В╤Л 10sтЖТ60s (stale), 60sтЖТ120s (predict). ╨Я╤А╤П╨╝╤Л╨╡ ╨▓╤Л╨╖╨╛╨▓╤Л: stale OK 13.0s, predict OK 1.4s; 62 ╤В╨╡╤Б╤В╨░ passed. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed (code only, MCP reload ╤В╤А╨╡╨▒╤Г╨╡╤В╤Б╤П) -## 2026-09-06 — lock_guard acquire/release падал ThreadExpired: таймаут 60s < pre-commit hook 5-10min (fixed) +## 2026-09-06 тАФ lock_guard acquire/release ╨┐╨░╨┤╨░╨╗ ThreadExpired: ╤В╨░╨╣╨╝╨░╤Г╤В 60s < pre-commit hook 5-10min (fixed) -- **Источник:** AGENT_DIARY.md#2026-09-06-2100 -- **Описание:** `scripts/lock_guard.py` (`_run`) использовал `timeout=60s` для `git commit`, но любой commit прогоняет pre-commit hook (verify_diary → полный pytest), занимающий 5-10 мин на Windows. 60s давал TimeoutExpired даже когда коммит успешно создавался в фоне → ложное ощущение провала протокола `.locks` при параллельной работе агентов. -- **Fix:** `_run` timeout 60→900s. Проверено полным циклом acquire→status→release на `README.md`, `scripts/lock_guard.py`, тестовом ресурсе: exit 0, коммиты+push проходят hook. INC-CD6E. -- **Статус:** ✅ Fixed +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md#2026-09-06-2100 +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** `scripts/lock_guard.py` (`_run`) ╨╕╤Б╨┐╨╛╨╗╤М╨╖╨╛╨▓╨░╨╗ `timeout=60s` ╨┤╨╗╤П `git commit`, ╨╜╨╛ ╨╗╤О╨▒╨╛╨╣ commit ╨┐╤А╨╛╨│╨╛╨╜╤П╨╡╤В pre-commit hook (verify_diary тЖТ ╨┐╨╛╨╗╨╜╤Л╨╣ pytest), ╨╖╨░╨╜╨╕╨╝╨░╤О╤Й╨╕╨╣ 5-10 ╨╝╨╕╨╜ ╨╜╨░ Windows. 60s ╨┤╨░╨▓╨░╨╗ TimeoutExpired ╨┤╨░╨╢╨╡ ╨║╨╛╨│╨┤╨░ ╨║╨╛╨╝╨╝╨╕╤В ╤Г╤Б╨┐╨╡╤И╨╜╨╛ ╤Б╨╛╨╖╨┤╨░╨▓╨░╨╗╤Б╤П ╨▓ ╤Д╨╛╨╜╨╡ тЖТ ╨╗╨╛╨╢╨╜╨╛╨╡ ╨╛╤Й╤Г╤Й╨╡╨╜╨╕╨╡ ╨┐╤А╨╛╨▓╨░╨╗╨░ ╨┐╤А╨╛╤В╨╛╨║╨╛╨╗╨░ `.locks` ╨┐╤А╨╕ ╨┐╨░╤А╨░╨╗╨╗╨╡╨╗╤М╨╜╨╛╨╣ ╤А╨░╨▒╨╛╤В╨╡ ╨░╨│╨╡╨╜╤В╨╛╨▓. +- **Fix:** `_run` timeout 60тЖТ900s. ╨Я╤А╨╛╨▓╨╡╤А╨╡╨╜╨╛ ╨┐╨╛╨╗╨╜╤Л╨╝ ╤Ж╨╕╨║╨╗╨╛╨╝ acquireтЖТstatusтЖТrelease ╨╜╨░ `README.md`, `scripts/lock_guard.py`, ╤В╨╡╤Б╤В╨╛╨▓╨╛╨╝ ╤А╨╡╤Б╤Г╤А╤Б╨╡: exit 0, ╨║╨╛╨╝╨╝╨╕╤В╤Л+push ╨┐╤А╨╛╤Е╨╛╨┤╤П╤В hook. INC-CD6E. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed -## 2026-09-06 — [P] sync-subprocess в async-MCP вызовов (context_tool, system_tools) — fixed +## 2026-09-06 тАФ [P] sync-subprocess ╨▓ async-MCP ╨▓╤Л╨╖╨╛╨▓╨╛╨▓ (context_tool, system_tools) тАФ fixed -- **Источник:** AGENT_DIARY.md#2026-09-06-2130; кандидаты: `context_tool.py:280` subprocess.run в get_context (30s), `system_tools.py:370/408/446` (dual_arm, mutmut-WSL 180s). -- **Fix:** `_section_git` стал async, `subprocess.run` обёрнут в `asyncio.to_thread` (context_tool.py); wsl_check/`_run_mutmut_in_wsl`/`_verify_mutmut_can_fail` — через `asyncio.to_thread` (system_tools.py). `git_tools._git_run` уже был async (эталон, не тронут). Проверено: test_context_tool 2 passed, ruff clean, импорты OK, реальный `_section_git` возвращает git-history. -- **Статус:** ✅ Fixed (code only, MCP reload требуется) +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md#2026-09-06-2130; ╨║╨░╨╜╨┤╨╕╨┤╨░╤В╤Л: `context_tool.py:280` subprocess.run ╨▓ get_context (30s), `system_tools.py:370/408/446` (dual_arm, mutmut-WSL 180s). +- **Fix:** `_section_git` ╤Б╤В╨░╨╗ async, `subprocess.run` ╨╛╨▒╤С╤А╨╜╤Г╤В ╨▓ `asyncio.to_thread` (context_tool.py); wsl_check/`_run_mutmut_in_wsl`/`_verify_mutmut_can_fail` тАФ ╤З╨╡╤А╨╡╨╖ `asyncio.to_thread` (system_tools.py). `git_tools._git_run` ╤Г╨╢╨╡ ╨▒╤Л╨╗ async (╤Н╤В╨░╨╗╨╛╨╜, ╨╜╨╡ ╤В╤А╨╛╨╜╤Г╤В). ╨Я╤А╨╛╨▓╨╡╤А╨╡╨╜╨╛: test_context_tool 2 passed, ruff clean, ╨╕╨╝╨┐╨╛╤А╤В╤Л OK, ╤А╨╡╨░╨╗╤М╨╜╤Л╨╣ `_section_git` ╨▓╨╛╨╖╨▓╤А╨░╤Й╨░╨╡╤В git-history. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed (code only, MCP reload ╤В╤А╨╡╨▒╤Г╨╡╤В╤Б╤П) -## 2026-09-06 — [P-001 рецидив] cmd-окна при запуске/открытии проекта: powershell/nvidia-smi БЕЗ CREATE_NO_WINDOW (fixed) +## 2026-09-06 тАФ [P-001 ╤А╨╡╤Ж╨╕╨┤╨╕╨▓] cmd-╨╛╨║╨╜╨░ ╨┐╤А╨╕ ╨╖╨░╨┐╤Г╤Б╨║╨╡/╨╛╤В╨║╤А╤Л╤В╨╕╨╕ ╨┐╤А╨╛╨╡╨║╤В╨░: powershell/nvidia-smi ╨С╨Х╨Ч CREATE_NO_WINDOW (fixed) -- **Источник:** AGENT_DIARY.md#2026-09-06-2200 -- **Описание:** Повтор P-001 (фикс 2026-08-14 пропустил сайты): `resource_monitor.py:303` (powershell Get-CimInstance RAM) и `:503` (nvidia-smi) БЕЗ creationflags; `llama_runner.py:1338/1366/1394` (powershell Get-NetTCPConnection/Get-CimInstance/taskkill в kill_process_on_port) БЕЗ флага. Дочерние консольные процессы (git/netstat) защищены, а powershell/nvidia-smi из фоновых сервисов — открывали видимое окно cmd при каждом открытии/запуске проекта (pythonw не подавляет создание консоли). -- **Fix:** CREATE_NO_WINDOW добавлен во все 5 сайтов (3 файла: resource_monitor.py ×2, llama_runner.py ×3). Guard: `tests/test_subprocess_windows.py` — из placeholder'ов превращён в реальный статический тест (grep по всем src/**/*.py за консоль-спавнами powershell/wsl/wmic/netstat/taskkill/nvidia-smi без флага → fail) + тест daemon-потоки без capture_output. Прогон: 2 passed. -- **Статус:** ✅ Fixed +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md#2026-09-06-2200 +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** ╨Я╨╛╨▓╤В╨╛╤А P-001 (╤Д╨╕╨║╤Б 2026-08-14 ╨┐╤А╨╛╨┐╤Г╤Б╤В╨╕╨╗ ╤Б╨░╨╣╤В╤Л): `resource_monitor.py:303` (powershell Get-CimInstance RAM) ╨╕ `:503` (nvidia-smi) ╨С╨Х╨Ч creationflags; `llama_runner.py:1338/1366/1394` (powershell Get-NetTCPConnection/Get-CimInstance/taskkill ╨▓ kill_process_on_port) ╨С╨Х╨Ч ╤Д╨╗╨░╨│╨░. ╨Ф╨╛╤З╨╡╤А╨╜╨╕╨╡ ╨║╨╛╨╜╤Б╨╛╨╗╤М╨╜╤Л╨╡ ╨┐╤А╨╛╤Ж╨╡╤Б╤Б╤Л (git/netstat) ╨╖╨░╤Й╨╕╤Й╨╡╨╜╤Л, ╨░ powershell/nvidia-smi ╨╕╨╖ ╤Д╨╛╨╜╨╛╨▓╤Л╤Е ╤Б╨╡╤А╨▓╨╕╤Б╨╛╨▓ тАФ ╨╛╤В╨║╤А╤Л╨▓╨░╨╗╨╕ ╨▓╨╕╨┤╨╕╨╝╨╛╨╡ ╨╛╨║╨╜╨╛ cmd ╨┐╤А╨╕ ╨║╨░╨╢╨┤╨╛╨╝ ╨╛╤В╨║╤А╤Л╤В╨╕╨╕/╨╖╨░╨┐╤Г╤Б╨║╨╡ ╨┐╤А╨╛╨╡╨║╤В╨░ (pythonw ╨╜╨╡ ╨┐╨╛╨┤╨░╨▓╨╗╤П╨╡╤В ╤Б╨╛╨╖╨┤╨░╨╜╨╕╨╡ ╨║╨╛╨╜╤Б╨╛╨╗╨╕). +- **Fix:** CREATE_NO_WINDOW ╨┤╨╛╨▒╨░╨▓╨╗╨╡╨╜ ╨▓╨╛ ╨▓╤Б╨╡ 5 ╤Б╨░╨╣╤В╨╛╨▓ (3 ╤Д╨░╨╣╨╗╨░: resource_monitor.py ├Ч2, llama_runner.py ├Ч3). Guard: `tests/test_subprocess_windows.py` тАФ ╨╕╨╖ placeholder'╨╛╨▓ ╨┐╤А╨╡╨▓╤А╨░╤Й╤С╨╜ ╨▓ ╤А╨╡╨░╨╗╤М╨╜╤Л╨╣ ╤Б╤В╨░╤В╨╕╤З╨╡╤Б╨║╨╕╨╣ ╤В╨╡╤Б╤В (grep ╨┐╨╛ ╨▓╤Б╨╡╨╝ src/**/*.py ╨╖╨░ ╨║╨╛╨╜╤Б╨╛╨╗╤М-╤Б╨┐╨░╨▓╨╜╨░╨╝╨╕ powershell/wsl/wmic/netstat/taskkill/nvidia-smi ╨▒╨╡╨╖ ╤Д╨╗╨░╨│╨░ тЖТ fail) + ╤В╨╡╤Б╤В daemon-╨┐╨╛╤В╨╛╨║╨╕ ╨▒╨╡╨╖ capture_output. ╨Я╤А╨╛╨│╨╛╨╜: 2 passed. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed -## 2026-09-07 — Cypher-движок ломается на анонимных узлах/рёбрах (fixed) + Receipts не писались из write-пути (fixed) + collect() некорректно заявлен (open) +## 2026-09-07 тАФ Cypher-╨┤╨▓╨╕╨╢╨╛╨║ ╨╗╨╛╨╝╨░╨╡╤В╤Б╤П ╨╜╨░ ╨░╨╜╨╛╨╜╨╕╨╝╨╜╤Л╤Е ╤Г╨╖╨╗╨░╤Е/╤А╤С╨▒╤А╨░╤Е (fixed) + Receipts ╨╜╨╡ ╨┐╨╕╤Б╨░╨╗╨╕╤Б╤М ╨╕╨╖ write-╨┐╤Г╤В╨╕ (fixed) + collect() ╨╜╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╨╛ ╨╖╨░╤П╨▓╨╗╨╡╨╜ (open) -- **Источник:** live-проба против реальной БД `bfe9644b/graph.db` (PropertyGraph, 6435 Variable / 22031 CALLS / 6152 ASSIGNED_FROM рёбер) -- **Описание (Cypher, fixed):** работают только запросы с типизированными узлами: `MATCH (n:Variable) RETURN count(n)` → 6435 (1.1ms). НО `MATCH ()-[e:ASSIGNED_FROM]->()` падал `sqlite3.OperationalError: no such column: e`, а `MATCH ()-[:ASSIGNED_FROM]->()` — `no such column: n0.id`. **Fix внесён:** cypher_sql.py — (1) `from_node_alias` резолвится в `n{path_idx*2}` для анонимного левого узла; (2) переменные ребра `[e:]` регистрируются в `edge_vars` и резолвятся в колонки (`e.type/source_id/target_id`), включён `count(e)`. 10 регресс-тестов (SQL + E2E) + 5 Red Team атак (направления `<-`, WHERE e.target_id, OPTIONAL MATCH, оба анонимных конца, collect) — все защищены, корректность результатов подтверждена (count=2 для 2 рёбер). **⚠️ collect() остаётся нерабочим**: `_translate_return_expr` заявляет `collect` как Supported (стр. 434-438 «Supported: count, sum, avg, min, max, collect»), но SQLite не имеет функции COLLECT (Red Team: `no such function: COLLECT`). Ни одного теста на `RETURN collect(...)` нет — заявка и реализация расходятся. -- **Описание (Receipts, fixed):** ActionReceipt компонент реализован (action_receipt.py, TD §11), но в проекте bfe9644b файла `action_receipts.jsonl` НЕТ — писались только в проектах 48baae8f/98d66cfa (19.08); `change_intents.jsonl` (96 записей) остаётся последней живой записью от 13.08. Receipt-путь для текущего проекта не срабатывал при повседневных MCP-вызовах (заполнялся только через lifecycle-tools reindex-путь). -- **Fix (Cypher):** внесён (см. выше, коммит 80a7acf8). **Fix (collect):** либо реализовать JSON-агрегацию `collect()` (json_group_array в SQLite), либо убрать из списка Supported и добавить негативный тест. **Fix (Receipts):** внесён — `_contract_record` в write_tools.py теперь вызывает новый `_contract_receipt()` (ActionReceipt рядом с ChangeIntent), а сам `_contract_record` добавлен во ВСЕ write-пути: replace, insert_before/after, rename (LSP workspace edit + fallback), safe_delete, move (source/target/refs). Receipt-запись warning-only, не ломает write. Тест `tests/test_write_tools.py::test_apply_records_action_receipt` (создание action_receipts.jsonl из реального write-вызова). Коммит см. git log. -- **Статус:** 🟢 Cypher-часть fixed; 🟢 receipts fixed; 🟢 collect() fixed (2026-09-08: json_group_array + FILTER null-игнор, decode только marked-колонок; 13 новых тестов, полный pytest 1663 passed) +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** live-╨┐╤А╨╛╨▒╨░ ╨┐╤А╨╛╤В╨╕╨▓ ╤А╨╡╨░╨╗╤М╨╜╨╛╨╣ ╨С╨Ф `bfe9644b/graph.db` (PropertyGraph, 6435 Variable / 22031 CALLS / 6152 ASSIGNED_FROM ╤А╤С╨▒╨╡╤А) +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡ (Cypher, fixed):** ╤А╨░╨▒╨╛╤В╨░╤О╤В ╤В╨╛╨╗╤М╨║╨╛ ╨╖╨░╨┐╤А╨╛╤Б╤Л ╤Б ╤В╨╕╨┐╨╕╨╖╨╕╤А╨╛╨▓╨░╨╜╨╜╤Л╨╝╨╕ ╤Г╨╖╨╗╨░╨╝╨╕: `MATCH (n:Variable) RETURN count(n)` тЖТ 6435 (1.1ms). ╨Э╨Ю `MATCH ()-[e:ASSIGNED_FROM]->()` ╨┐╨░╨┤╨░╨╗ `sqlite3.OperationalError: no such column: e`, ╨░ `MATCH ()-[:ASSIGNED_FROM]->()` тАФ `no such column: n0.id`. **Fix ╨▓╨╜╨╡╤Б╤С╨╜:** cypher_sql.py тАФ (1) `from_node_alias` ╤А╨╡╨╖╨╛╨╗╨▓╨╕╤В╤Б╤П ╨▓ `n{path_idx*2}` ╨┤╨╗╤П ╨░╨╜╨╛╨╜╨╕╨╝╨╜╨╛╨│╨╛ ╨╗╨╡╨▓╨╛╨│╨╛ ╤Г╨╖╨╗╨░; (2) ╨┐╨╡╤А╨╡╨╝╨╡╨╜╨╜╤Л╨╡ ╤А╨╡╨▒╤А╨░ `[e:]` ╤А╨╡╨│╨╕╤Б╤В╤А╨╕╤А╤Г╤О╤В╤Б╤П ╨▓ `edge_vars` ╨╕ ╤А╨╡╨╖╨╛╨╗╨▓╤П╤В╤Б╤П ╨▓ ╨║╨╛╨╗╨╛╨╜╨║╨╕ (`e.type/source_id/target_id`), ╨▓╨║╨╗╤О╤З╤С╨╜ `count(e)`. 10 ╤А╨╡╨│╤А╨╡╤Б╤Б-╤В╨╡╤Б╤В╨╛╨▓ (SQL + E2E) + 5 Red Team ╨░╤В╨░╨║ (╨╜╨░╨┐╤А╨░╨▓╨╗╨╡╨╜╨╕╤П `<-`, WHERE e.target_id, OPTIONAL MATCH, ╨╛╨▒╨░ ╨░╨╜╨╛╨╜╨╕╨╝╨╜╤Л╤Е ╨║╨╛╨╜╤Ж╨░, collect) тАФ ╨▓╤Б╨╡ ╨╖╨░╤Й╨╕╤Й╨╡╨╜╤Л, ╨║╨╛╤А╤А╨╡╨║╤В╨╜╨╛╤Б╤В╤М ╤А╨╡╨╖╤Г╨╗╤М╤В╨░╤В╨╛╨▓ ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨╡╨╜╨░ (count=2 ╨┤╨╗╤П 2 ╤А╤С╨▒╨╡╤А). **тЪая╕П collect() ╨╛╤Б╤В╨░╤С╤В╤Б╤П ╨╜╨╡╤А╨░╨▒╨╛╤З╨╕╨╝**: `_translate_return_expr` ╨╖╨░╤П╨▓╨╗╤П╨╡╤В `collect` ╨║╨░╨║ Supported (╤Б╤В╤А. 434-438 ┬лSupported: count, sum, avg, min, max, collect┬╗), ╨╜╨╛ SQLite ╨╜╨╡ ╨╕╨╝╨╡╨╡╤В ╤Д╤Г╨╜╨║╤Ж╨╕╨╕ COLLECT (Red Team: `no such function: COLLECT`). ╨Э╨╕ ╨╛╨┤╨╜╨╛╨│╨╛ ╤В╨╡╤Б╤В╨░ ╨╜╨░ `RETURN collect(...)` ╨╜╨╡╤В тАФ ╨╖╨░╤П╨▓╨║╨░ ╨╕ ╤А╨╡╨░╨╗╨╕╨╖╨░╤Ж╨╕╤П ╤А╨░╤Б╤Е╨╛╨┤╤П╤В╤Б╤П. +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡ (Receipts, fixed):** ActionReceipt ╨║╨╛╨╝╨┐╨╛╨╜╨╡╨╜╤В ╤А╨╡╨░╨╗╨╕╨╖╨╛╨▓╨░╨╜ (action_receipt.py, TD ┬з11), ╨╜╨╛ ╨▓ ╨┐╤А╨╛╨╡╨║╤В╨╡ bfe9644b ╤Д╨░╨╣╨╗╨░ `action_receipts.jsonl` ╨Э╨Х╨в тАФ ╨┐╨╕╤Б╨░╨╗╨╕╤Б╤М ╤В╨╛╨╗╤М╨║╨╛ ╨▓ ╨┐╤А╨╛╨╡╨║╤В╨░╤Е 48baae8f/98d66cfa (19.08); `change_intents.jsonl` (96 ╨╖╨░╨┐╨╕╤Б╨╡╨╣) ╨╛╤Б╤В╨░╤С╤В╤Б╤П ╨┐╨╛╤Б╨╗╨╡╨┤╨╜╨╡╨╣ ╨╢╨╕╨▓╨╛╨╣ ╨╖╨░╨┐╨╕╤Б╤М╤О ╨╛╤В 13.08. Receipt-╨┐╤Г╤В╤М ╨┤╨╗╤П ╤В╨╡╨║╤Г╤Й╨╡╨│╨╛ ╨┐╤А╨╛╨╡╨║╤В╨░ ╨╜╨╡ ╤Б╤А╨░╨▒╨░╤В╤Л╨▓╨░╨╗ ╨┐╤А╨╕ ╨┐╨╛╨▓╤Б╨╡╨┤╨╜╨╡╨▓╨╜╤Л╤Е MCP-╨▓╤Л╨╖╨╛╨▓╨░╤Е (╨╖╨░╨┐╨╛╨╗╨╜╤П╨╗╤Б╤П ╤В╨╛╨╗╤М╨║╨╛ ╤З╨╡╤А╨╡╨╖ lifecycle-tools reindex-╨┐╤Г╤В╤М). +- **Fix (Cypher):** ╨▓╨╜╨╡╤Б╤С╨╜ (╤Б╨╝. ╨▓╤Л╤И╨╡, ╨║╨╛╨╝╨╝╨╕╤В 80a7acf8). **Fix (collect):** ╨╗╨╕╨▒╨╛ ╤А╨╡╨░╨╗╨╕╨╖╨╛╨▓╨░╤В╤М JSON-╨░╨│╤А╨╡╨│╨░╤Ж╨╕╤О `collect()` (json_group_array ╨▓ SQLite), ╨╗╨╕╨▒╨╛ ╤Г╨▒╤А╨░╤В╤М ╨╕╨╖ ╤Б╨┐╨╕╤Б╨║╨░ Supported ╨╕ ╨┤╨╛╨▒╨░╨▓╨╕╤В╤М ╨╜╨╡╨│╨░╤В╨╕╨▓╨╜╤Л╨╣ ╤В╨╡╤Б╤В. **Fix (Receipts):** ╨▓╨╜╨╡╤Б╤С╨╜ тАФ `_contract_record` ╨▓ write_tools.py ╤В╨╡╨┐╨╡╤А╤М ╨▓╤Л╨╖╤Л╨▓╨░╨╡╤В ╨╜╨╛╨▓╤Л╨╣ `_contract_receipt()` (ActionReceipt ╤А╤П╨┤╨╛╨╝ ╤Б ChangeIntent), ╨░ ╤Б╨░╨╝ `_contract_record` ╨┤╨╛╨▒╨░╨▓╨╗╨╡╨╜ ╨▓╨╛ ╨Т╨б╨Х write-╨┐╤Г╤В╨╕: replace, insert_before/after, rename (LSP workspace edit + fallback), safe_delete, move (source/target/refs). Receipt-╨╖╨░╨┐╨╕╤Б╤М warning-only, ╨╜╨╡ ╨╗╨╛╨╝╨░╨╡╤В write. ╨в╨╡╤Б╤В `tests/test_write_tools.py::test_apply_records_action_receipt` (╤Б╨╛╨╖╨┤╨░╨╜╨╕╨╡ action_receipts.jsonl ╨╕╨╖ ╤А╨╡╨░╨╗╤М╨╜╨╛╨│╨╛ write-╨▓╤Л╨╖╨╛╨▓╨░). ╨Ъ╨╛╨╝╨╝╨╕╤В ╤Б╨╝. git log. +- **╨б╤В╨░╤В╤Г╤Б:** ЁЯЯв Cypher-╤З╨░╤Б╤В╤М fixed; ЁЯЯв receipts fixed; ЁЯЯв collect() fixed (2026-09-08: json_group_array + FILTER null-╨╕╨│╨╜╨╛╤А, decode ╤В╨╛╨╗╤М╨║╨╛ marked-╨║╨╛╨╗╨╛╨╜╨╛╨║; 13 ╨╜╨╛╨▓╤Л╤Е ╤В╨╡╤Б╤В╨╛╨▓, ╨┐╨╛╨╗╨╜╤Л╨╣ pytest 1663 passed) -## 2026-09-07 — Lazy-only верификация: память не проверяется без вызова агента; нет TTL/фона (open, эксперимент нужен) +## 2026-09-07 тАФ Lazy-only ╨▓╨╡╤А╨╕╤Д╨╕╨║╨░╤Ж╨╕╤П: ╨┐╨░╨╝╤П╤В╤М ╨╜╨╡ ╨┐╤А╨╛╨▓╨╡╤А╤П╨╡╤В╤Б╤П ╨▒╨╡╨╖ ╨▓╤Л╨╖╨╛╨▓╨░ ╨░╨│╨╡╨╜╤В╨░; ╨╜╨╡╤В TTL/╤Д╨╛╨╜╨░ (open, ╤Н╨║╤Б╨┐╨╡╤А╨╕╨╝╨╡╨╜╤В ╨╜╤Г╨╢╨╡╨╜) -- **Источник:** live-срез project_memory.json текущего проекта (136 узлов) + grep точек вызова VOR/idle-планировщика -- **Симптомы (все Verified):** - - VOR вызывается ровно из 1 места — `intel_get_project_memory` (layer.py:1097). Таймеров/старт-хуков/idle-подписок нет. - - У 42 узлов ACTIVE нет ни одного поля TTL/last_checked/next_check — висят без статуса с 2026-08-11 (1 месяц). - - Узлы без якорей (`file:/import:/env:/pkg:`) → INCONCLUSIVE → VOR **не пишет ничего** (ни статуса, ни verified_at) — их нельзя ни подтвердить, ни отозвать автоматически. Пример: ADRs с commit_hash в data, но без шпилей. - - IdleScheduler (`enable_idle_scheduler`, task_queue.py:345) включается только из `record_tool_call()` — после вызова инструмента; VOR туда не подключён; из 3 idle-задач 2 — заглушки (`_improve_summaries_batch`, `_check_index_health` — пустые тела, только debug-лог). - - Со стороны агента: вызвал `intel_get_project_memory` → 110/110 узлов проверено (47 VERIFIED, 63 не-refuted) — работает, но только «по руке». -- **Дизайн-решение для эксперимента (следующий шаг):** непрерывная проверка «без вызова» — (a) idle-тикер VOR в фоне по расписанию с cooldown; (b) react на git/файловые события (HEAD сменился → перепроверка затронутых узлов); (c) TTL/`verified_at` для INCONCLUSIVE → по возрастанию падать в REFUTED label «не подтверждён за N дней». Контр-риск: цена (CPU/disk) непрерывной проверки vs польза свежести — мерить, не угадывать (см. docs/research/universal-engine-study/10-continuous-verification.md). -- **2026-09-09 аудит (Exhibit #23) подтверждает:** цепочка «файл изменён → STALE → VOR → alert агента» не существует ни в одном звене; ConsistencyTracker.mark_stale("memory") никогда не вызывается; system_alerts нет. Red Team: (a) H3 TTL-гниение НЕ применимо к INCONCLUSIVE (VOR статус не меняется без якорей) — нужен idle-ticker H1; (b) lock contention idle-VOR vs agent-VOR — добавить locked()-check; (c) import cycle — локальный импорт внутри try/except. Выбран приоритет: **H1 (idle-ticker в `_check_index_health`, ~15 строк)**. -- **2026-09-09 H1 реализован (коммит в ветке chore/experiments-es1-es2-0909):** `set_idle_vor_callback()` в task_queue.py + вызов из `_check_index_health` (idle-тик, cooldown 120s); `run_background_verify(budget_ms=250)` в layer.py с locked()-guard (Red Team a): общий `_write_lock` и `get_verifier`-регистр → idle-VOR и agent-VOR сериализуются без второй lock/гонки; `_build_symbol_resolver` вынесен из `intel_get_project_memory` (DRY, эквивалентный рефакторинг); регистрация hook в `server_tools._register_intelligence_tools` после создания `intel_layer`. Тесты: 3 idle-hook (test_task_queue) + 3 background-VOR (test_verify_on_read); полный pytest 1674 passed. -- **Статус:** ✅ H1 Fixed (фоновая перепроверка памяти без вызова агента); ✅ system_alerts Fixed (см. ниже — доставка через alert-store реализована); данная дочерняя запись про `.h` закрыта -- **2026-09-10 system_alerts реализован (коммит в ветке chore/experiments-es1-es2-0909):** `AlertStore` (src/core/intelligence/alert_store.py, JSON вне проекта, threading.Lock, дедуп по kind+payload, атомарный collect_and_clear). Источники: stale — `mark_stale("memory")` + одноразовый alert в notify_change (при переходе UNKNOWN/CONSISTENT→STALE); starved — idle VOR-проход (`run_background_verify`) и `intel_get_project_memory` (узлы видимы ≥2 циклов, MATCHED>0, DELIVERED=0). Доставка: `format_system_alerts` в ui_formatter; prepend в `intel_get_project_memory` (tools_reg) + последняя секция в `intel_explain_project_state` (server_tools). Red Team: дедуп предотвращает спам на каждый notify_change; атомарный clear — один alert уходит ровно одному тулу при гонке; limit=5 — токен-бюджет. Тесты: 11 (test_alert_store: push/collect/clear/дедуп/limit/коррапт-json/гонка 2 потоков/per-project/синглтон) + 3 (format_system_alerts); полный pytest 1689 passed. -- **2026-09-11 H3 TTL-гниение реализован (doc 10, H2 закрыт Exp 3):** `last_checked` пишется для КАЖДОГО реально проверенного узла (cache-hit и fresh check, включая INCONCLUSIVE) — физическая запись rate-limited (`VOR_LAST_CHECKED_INTERVAL_SEC`, default 6ч, чтобы H1 idle не переписывал project_memory.json каждый тик); `stale_ttl_nodes` — узлы ACTIVE/VERIFIED, НЕ проверенные в проходе, чей след (`verified_at`/`last_checked`) старше `VOR_TTL_DAYS` (default 30 → label `verification="stale_ttl"` «не подтверждён за N дней»). N=30 из live-распределения verified_at 2026-09-11 (ACTIVE 70 без verified_at, VERIFIED median 22/max 31, коммитовый ритм daily). Статус НЕ меняется (Red Team a2: INCONCLUSIVE неотзываем, guard false_retraction). Нет следа вовсе (новый узел) → НЕ stale (starved ловит систематическое голодание отдельно). Files: verify_on_read.py (const + _persist_transitions + run), layer.py (flag), ui_formatter.py (render). Тесты: 9 новых (test_verify_on_read_ttl.py); полный pytest 1725 passed. -- **Дедлайн:** 2026-09-15 · **Owner:** ManSio +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** live-╤Б╤А╨╡╨╖ project_memory.json ╤В╨╡╨║╤Г╤Й╨╡╨│╨╛ ╨┐╤А╨╛╨╡╨║╤В╨░ (136 ╤Г╨╖╨╗╨╛╨▓) + grep ╤В╨╛╤З╨╡╨║ ╨▓╤Л╨╖╨╛╨▓╨░ VOR/idle-╨┐╨╗╨░╨╜╨╕╤А╨╛╨▓╤Й╨╕╨║╨░ +- **╨б╨╕╨╝╨┐╤В╨╛╨╝╤Л (╨▓╤Б╨╡ Verified):** + - VOR ╨▓╤Л╨╖╤Л╨▓╨░╨╡╤В╤Б╤П ╤А╨╛╨▓╨╜╨╛ ╨╕╨╖ 1 ╨╝╨╡╤Б╤В╨░ тАФ `intel_get_project_memory` (layer.py:1097). ╨в╨░╨╣╨╝╨╡╤А╨╛╨▓/╤Б╤В╨░╤А╤В-╤Е╤Г╨║╨╛╨▓/idle-╨┐╨╛╨┤╨┐╨╕╤Б╨╛╨║ ╨╜╨╡╤В. + - ╨г 42 ╤Г╨╖╨╗╨╛╨▓ ACTIVE ╨╜╨╡╤В ╨╜╨╕ ╨╛╨┤╨╜╨╛╨│╨╛ ╨┐╨╛╨╗╤П TTL/last_checked/next_check тАФ ╨▓╨╕╤Б╤П╤В ╨▒╨╡╨╖ ╤Б╤В╨░╤В╤Г╤Б╨░ ╤Б 2026-08-11 (1 ╨╝╨╡╤Б╤П╤Ж). + - ╨г╨╖╨╗╤Л ╨▒╨╡╨╖ ╤П╨║╨╛╤А╨╡╨╣ (`file:/import:/env:/pkg:`) тЖТ INCONCLUSIVE тЖТ VOR **╨╜╨╡ ╨┐╨╕╤И╨╡╤В ╨╜╨╕╤З╨╡╨│╨╛** (╨╜╨╕ ╤Б╤В╨░╤В╤Г╤Б╨░, ╨╜╨╕ verified_at) тАФ ╨╕╤Е ╨╜╨╡╨╗╤М╨╖╤П ╨╜╨╕ ╨┐╨╛╨┤╤В╨▓╨╡╤А╨┤╨╕╤В╤М, ╨╜╨╕ ╨╛╤В╨╛╨╖╨▓╨░╤В╤М ╨░╨▓╤В╨╛╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╕. ╨Я╤А╨╕╨╝╨╡╤А: ADRs ╤Б commit_hash ╨▓ data, ╨╜╨╛ ╨▒╨╡╨╖ ╤И╨┐╨╕╨╗╨╡╨╣. + - IdleScheduler (`enable_idle_scheduler`, task_queue.py:345) ╨▓╨║╨╗╤О╤З╨░╨╡╤В╤Б╤П ╤В╨╛╨╗╤М╨║╨╛ ╨╕╨╖ `record_tool_call()` тАФ ╨┐╨╛╤Б╨╗╨╡ ╨▓╤Л╨╖╨╛╨▓╨░ ╨╕╨╜╤Б╤В╤А╤Г╨╝╨╡╨╜╤В╨░; VOR ╤В╤Г╨┤╨░ ╨╜╨╡ ╨┐╨╛╨┤╨║╨╗╤О╤З╤С╨╜; ╨╕╨╖ 3 idle-╨╖╨░╨┤╨░╤З 2 тАФ ╨╖╨░╨│╨╗╤Г╤И╨║╨╕ (`_improve_summaries_batch`, `_check_index_health` тАФ ╨┐╤Г╤Б╤В╤Л╨╡ ╤В╨╡╨╗╨░, ╤В╨╛╨╗╤М╨║╨╛ debug-╨╗╨╛╨│). + - ╨б╨╛ ╤Б╤В╨╛╤А╨╛╨╜╤Л ╨░╨│╨╡╨╜╤В╨░: ╨▓╤Л╨╖╨▓╨░╨╗ `intel_get_project_memory` тЖТ 110/110 ╤Г╨╖╨╗╨╛╨▓ ╨┐╤А╨╛╨▓╨╡╤А╨╡╨╜╨╛ (47 VERIFIED, 63 ╨╜╨╡-refuted) тАФ ╤А╨░╨▒╨╛╤В╨░╨╡╤В, ╨╜╨╛ ╤В╨╛╨╗╤М╨║╨╛ ┬л╨┐╨╛ ╤А╤Г╨║╨╡┬╗. +- **╨Ф╨╕╨╖╨░╨╣╨╜-╤А╨╡╤И╨╡╨╜╨╕╨╡ ╨┤╨╗╤П ╤Н╨║╤Б╨┐╨╡╤А╨╕╨╝╨╡╨╜╤В╨░ (╤Б╨╗╨╡╨┤╤Г╤О╤Й╨╕╨╣ ╤И╨░╨│):** ╨╜╨╡╨┐╤А╨╡╤А╤Л╨▓╨╜╨░╤П ╨┐╤А╨╛╨▓╨╡╤А╨║╨░ ┬л╨▒╨╡╨╖ ╨▓╤Л╨╖╨╛╨▓╨░┬╗ тАФ (a) idle-╤В╨╕╨║╨╡╤А VOR ╨▓ ╤Д╨╛╨╜╨╡ ╨┐╨╛ ╤А╨░╤Б╨┐╨╕╤Б╨░╨╜╨╕╤О ╤Б cooldown; (b) react ╨╜╨░ git/╤Д╨░╨╣╨╗╨╛╨▓╤Л╨╡ ╤Б╨╛╨▒╤Л╤В╨╕╤П (HEAD ╤Б╨╝╨╡╨╜╨╕╨╗╤Б╤П тЖТ ╨┐╨╡╤А╨╡╨┐╤А╨╛╨▓╨╡╤А╨║╨░ ╨╖╨░╤В╤А╨╛╨╜╤Г╤В╤Л╤Е ╤Г╨╖╨╗╨╛╨▓); (c) TTL/`verified_at` ╨┤╨╗╤П INCONCLUSIVE тЖТ ╨┐╨╛ ╨▓╨╛╨╖╤А╨░╤Б╤В╨░╨╜╨╕╤О ╨┐╨░╨┤╨░╤В╤М ╨▓ REFUTED label ┬л╨╜╨╡ ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╤С╨╜ ╨╖╨░ N ╨┤╨╜╨╡╨╣┬╗. ╨Ъ╨╛╨╜╤В╤А-╤А╨╕╤Б╨║: ╤Ж╨╡╨╜╨░ (CPU/disk) ╨╜╨╡╨┐╤А╨╡╤А╤Л╨▓╨╜╨╛╨╣ ╨┐╤А╨╛╨▓╨╡╤А╨║╨╕ vs ╨┐╨╛╨╗╤М╨╖╨░ ╤Б╨▓╨╡╨╢╨╡╤Б╤В╨╕ тАФ ╨╝╨╡╤А╨╕╤В╤М, ╨╜╨╡ ╤Г╨│╨░╨┤╤Л╨▓╨░╤В╤М (╤Б╨╝. docs/research/universal-engine-study/10-continuous-verification.md). +- **2026-09-09 ╨░╤Г╨┤╨╕╤В (Exhibit #23) ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨░╨╡╤В:** ╤Ж╨╡╨┐╨╛╤З╨║╨░ ┬л╤Д╨░╨╣╨╗ ╨╕╨╖╨╝╨╡╨╜╤С╨╜ тЖТ STALE тЖТ VOR тЖТ alert ╨░╨│╨╡╨╜╤В╨░┬╗ ╨╜╨╡ ╤Б╤Г╤Й╨╡╤Б╤В╨▓╤Г╨╡╤В ╨╜╨╕ ╨▓ ╨╛╨┤╨╜╨╛╨╝ ╨╖╨▓╨╡╨╜╨╡; ConsistencyTracker.mark_stale("memory") ╨╜╨╕╨║╨╛╨│╨┤╨░ ╨╜╨╡ ╨▓╤Л╨╖╤Л╨▓╨░╨╡╤В╤Б╤П; system_alerts ╨╜╨╡╤В. Red Team: (a) H3 TTL-╨│╨╜╨╕╨╡╨╜╨╕╨╡ ╨Э╨Х ╨┐╤А╨╕╨╝╨╡╨╜╨╕╨╝╨╛ ╨║ INCONCLUSIVE (VOR ╤Б╤В╨░╤В╤Г╤Б ╨╜╨╡ ╨╝╨╡╨╜╤П╨╡╤В╤Б╤П ╨▒╨╡╨╖ ╤П╨║╨╛╤А╨╡╨╣) тАФ ╨╜╤Г╨╢╨╡╨╜ idle-ticker H1; (b) lock contention idle-VOR vs agent-VOR тАФ ╨┤╨╛╨▒╨░╨▓╨╕╤В╤М locked()-check; (c) import cycle тАФ ╨╗╨╛╨║╨░╨╗╤М╨╜╤Л╨╣ ╨╕╨╝╨┐╨╛╤А╤В ╨▓╨╜╤Г╤В╤А╨╕ try/except. ╨Т╤Л╨▒╤А╨░╨╜ ╨┐╤А╨╕╨╛╤А╨╕╤В╨╡╤В: **H1 (idle-ticker ╨▓ `_check_index_health`, ~15 ╤Б╤В╤А╨╛╨║)**. +- **2026-09-09 H1 ╤А╨╡╨░╨╗╨╕╨╖╨╛╨▓╨░╨╜ (╨║╨╛╨╝╨╝╨╕╤В ╨▓ ╨▓╨╡╤В╨║╨╡ chore/experiments-es1-es2-0909):** `set_idle_vor_callback()` ╨▓ task_queue.py + ╨▓╤Л╨╖╨╛╨▓ ╨╕╨╖ `_check_index_health` (idle-╤В╨╕╨║, cooldown 120s); `run_background_verify(budget_ms=250)` ╨▓ layer.py ╤Б locked()-guard (Red Team a): ╨╛╨▒╤Й╨╕╨╣ `_write_lock` ╨╕ `get_verifier`-╤А╨╡╨│╨╕╤Б╤В╤А тЖТ idle-VOR ╨╕ agent-VOR ╤Б╨╡╤А╨╕╨░╨╗╨╕╨╖╤Г╤О╤В╤Б╤П ╨▒╨╡╨╖ ╨▓╤В╨╛╤А╨╛╨╣ lock/╨│╨╛╨╜╨║╨╕; `_build_symbol_resolver` ╨▓╤Л╨╜╨╡╤Б╨╡╨╜ ╨╕╨╖ `intel_get_project_memory` (DRY, ╤Н╨║╨▓╨╕╨▓╨░╨╗╨╡╨╜╤В╨╜╤Л╨╣ ╤А╨╡╤Д╨░╨║╤В╨╛╤А╨╕╨╜╨│); ╤А╨╡╨│╨╕╤Б╤В╤А╨░╤Ж╨╕╤П hook ╨▓ `server_tools._register_intelligence_tools` ╨┐╨╛╤Б╨╗╨╡ ╤Б╨╛╨╖╨┤╨░╨╜╨╕╤П `intel_layer`. ╨в╨╡╤Б╤В╤Л: 3 idle-hook (test_task_queue) + 3 background-VOR (test_verify_on_read); ╨┐╨╛╨╗╨╜╤Л╨╣ pytest 1674 passed. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ H1 Fixed (╤Д╨╛╨╜╨╛╨▓╨░╤П ╨┐╨╡╤А╨╡╨┐╤А╨╛╨▓╨╡╤А╨║╨░ ╨┐╨░╨╝╤П╤В╨╕ ╨▒╨╡╨╖ ╨▓╤Л╨╖╨╛╨▓╨░ ╨░╨│╨╡╨╜╤В╨░); тЬЕ system_alerts Fixed (╤Б╨╝. ╨╜╨╕╨╢╨╡ тАФ ╨┤╨╛╤Б╤В╨░╨▓╨║╨░ ╤З╨╡╤А╨╡╨╖ alert-store ╤А╨╡╨░╨╗╨╕╨╖╨╛╨▓╨░╨╜╨░); ╨┤╨░╨╜╨╜╨░╤П ╨┤╨╛╤З╨╡╤А╨╜╤П╤П ╨╖╨░╨┐╨╕╤Б╤М ╨┐╤А╨╛ `.h` ╨╖╨░╨║╤А╤Л╤В╨░ +- **2026-09-10 system_alerts ╤А╨╡╨░╨╗╨╕╨╖╨╛╨▓╨░╨╜ (╨║╨╛╨╝╨╝╨╕╤В ╨▓ ╨▓╨╡╤В╨║╨╡ chore/experiments-es1-es2-0909):** `AlertStore` (src/core/intelligence/alert_store.py, JSON ╨▓╨╜╨╡ ╨┐╤А╨╛╨╡╨║╤В╨░, threading.Lock, ╨┤╨╡╨┤╤Г╨┐ ╨┐╨╛ kind+payload, ╨░╤В╨╛╨╝╨░╤А╨╜╤Л╨╣ collect_and_clear). ╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║╨╕: stale тАФ `mark_stale("memory")` + ╨╛╨┤╨╜╨╛╤А╨░╨╖╨╛╨▓╤Л╨╣ alert ╨▓ notify_change (╨┐╤А╨╕ ╨┐╨╡╤А╨╡╤Е╨╛╨┤╨╡ UNKNOWN/CONSISTENTтЖТSTALE); starved тАФ idle VOR-╨┐╤А╨╛╤Е╨╛╨┤ (`run_background_verify`) ╨╕ `intel_get_project_memory` (╤Г╨╖╨╗╤Л ╨▓╨╕╨┤╨╕╨╝╤Л тЙе2 ╤Ж╨╕╨║╨╗╨╛╨▓, MATCHED>0, DELIVERED=0). ╨Ф╨╛╤Б╤В╨░╨▓╨║╨░: `format_system_alerts` ╨▓ ui_formatter; prepend ╨▓ `intel_get_project_memory` (tools_reg) + ╨┐╨╛╤Б╨╗╨╡╨┤╨╜╤П╤П ╤Б╨╡╨║╤Ж╨╕╤П ╨▓ `intel_explain_project_state` (server_tools). Red Team: ╨┤╨╡╨┤╤Г╨┐ ╨┐╤А╨╡╨┤╨╛╤В╨▓╤А╨░╤Й╨░╨╡╤В ╤Б╨┐╨░╨╝ ╨╜╨░ ╨║╨░╨╢╨┤╤Л╨╣ notify_change; ╨░╤В╨╛╨╝╨░╤А╨╜╤Л╨╣ clear тАФ ╨╛╨┤╨╕╨╜ alert ╤Г╤Е╨╛╨┤╨╕╤В ╤А╨╛╨▓╨╜╨╛ ╨╛╨┤╨╜╨╛╨╝╤Г ╤В╤Г╨╗╤Г ╨┐╤А╨╕ ╨│╨╛╨╜╨║╨╡; limit=5 тАФ ╤В╨╛╨║╨╡╨╜-╨▒╤О╨┤╨╢╨╡╤В. ╨в╨╡╤Б╤В╤Л: 11 (test_alert_store: push/collect/clear/╨┤╨╡╨┤╤Г╨┐/limit/╨║╨╛╤А╤А╨░╨┐╤В-json/╨│╨╛╨╜╨║╨░ 2 ╨┐╨╛╤В╨╛╨║╨╛╨▓/per-project/╤Б╨╕╨╜╨│╨╗╤В╨╛╨╜) + 3 (format_system_alerts); ╨┐╨╛╨╗╨╜╤Л╨╣ pytest 1689 passed. +- **2026-09-11 H3 TTL-╨│╨╜╨╕╨╡╨╜╨╕╨╡ ╤А╨╡╨░╨╗╨╕╨╖╨╛╨▓╨░╨╜ (doc 10, H2 ╨╖╨░╨║╤А╤Л╤В Exp 3):** `last_checked` ╨┐╨╕╤И╨╡╤В╤Б╤П ╨┤╨╗╤П ╨Ъ╨Р╨Ц╨Ф╨Ю╨У╨Ю ╤А╨╡╨░╨╗╤М╨╜╨╛ ╨┐╤А╨╛╨▓╨╡╤А╨╡╨╜╨╜╨╛╨│╨╛ ╤Г╨╖╨╗╨░ (cache-hit ╨╕ fresh check, ╨▓╨║╨╗╤О╤З╨░╤П INCONCLUSIVE) тАФ ╤Д╨╕╨╖╨╕╤З╨╡╤Б╨║╨░╤П ╨╖╨░╨┐╨╕╤Б╤М rate-limited (`VOR_LAST_CHECKED_INTERVAL_SEC`, default 6╤З, ╤З╤В╨╛╨▒╤Л H1 idle ╨╜╨╡ ╨┐╨╡╤А╨╡╨┐╨╕╤Б╤Л╨▓╨░╨╗ project_memory.json ╨║╨░╨╢╨┤╤Л╨╣ ╤В╨╕╨║); `stale_ttl_nodes` тАФ ╤Г╨╖╨╗╤Л ACTIVE/VERIFIED, ╨Э╨Х ╨┐╤А╨╛╨▓╨╡╤А╨╡╨╜╨╜╤Л╨╡ ╨▓ ╨┐╤А╨╛╤Е╨╛╨┤╨╡, ╤З╨╡╨╣ ╤Б╨╗╨╡╨┤ (`verified_at`/`last_checked`) ╤Б╤В╨░╤А╤И╨╡ `VOR_TTL_DAYS` (default 30 тЖТ label `verification="stale_ttl"` ┬л╨╜╨╡ ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╤С╨╜ ╨╖╨░ N ╨┤╨╜╨╡╨╣┬╗). N=30 ╨╕╨╖ live-╤А╨░╤Б╨┐╤А╨╡╨┤╨╡╨╗╨╡╨╜╨╕╤П verified_at 2026-09-11 (ACTIVE 70 ╨▒╨╡╨╖ verified_at, VERIFIED median 22/max 31, ╨║╨╛╨╝╨╝╨╕╤В╨╛╨▓╤Л╨╣ ╤А╨╕╤В╨╝ daily). ╨б╤В╨░╤В╤Г╤Б ╨Э╨Х ╨╝╨╡╨╜╤П╨╡╤В╤Б╤П (Red Team a2: INCONCLUSIVE ╨╜╨╡╨╛╤В╨╖╤Л╨▓╨░╨╡╨╝, guard false_retraction). ╨Э╨╡╤В ╤Б╨╗╨╡╨┤╨░ ╨▓╨╛╨▓╤Б╨╡ (╨╜╨╛╨▓╤Л╨╣ ╤Г╨╖╨╡╨╗) тЖТ ╨Э╨Х stale (starved ╨╗╨╛╨▓╨╕╤В ╤Б╨╕╤Б╤В╨╡╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╛╨╡ ╨│╨╛╨╗╨╛╨┤╨░╨╜╨╕╨╡ ╨╛╤В╨┤╨╡╨╗╤М╨╜╨╛). Files: verify_on_read.py (const + _persist_transitions + run), layer.py (flag), ui_formatter.py (render). ╨в╨╡╤Б╤В╤Л: 9 ╨╜╨╛╨▓╤Л╤Е (test_verify_on_read_ttl.py); ╨┐╨╛╨╗╨╜╤Л╨╣ pytest 1725 passed. +- **╨Ф╨╡╨┤╨╗╨░╨╣╨╜:** 2026-09-15 ┬╖ **Owner:** ManSio -## 2026-09-08 — B4: статический цикл parser ⇄ language_imports (осознанный техдолг, lazy, allowed) +## 2026-09-08 тАФ B4: ╤Б╤В╨░╤В╨╕╤З╨╡╤Б╨║╨╕╨╣ ╤Ж╨╕╨║╨╗ parser тЗД language_imports (╨╛╤Б╨╛╨╖╨╜╨░╨╜╨╜╤Л╨╣ ╤В╨╡╤Е╨┤╨╛╨╗╨│, lazy, allowed) -- **Источник:** `architecture_linter` (Invariant 3) после деривации `LANGUAGE_IMPORT_NODES` из `CodeParser.IMPORT_NODE_MAP` (B4). -- **Описание:** `src.core.language_imports` импортирует `src.core.indexing.parser` (для деривации карты), а `parser._extract_fallback_imports` импортирует `language_imports` (fallback-режим 2). Статически — цикл; в рантайме ни один импорт при загрузке модулей не выполняется: parser импортирует language_imports только локально в функции; language_imports импортирует parser только лениво (module `__getattr__` → `_derive_language_import_nodes`, PEP 562) при первом обращении к `LANGUAGE_IMPORT_NODES`. -- **Fix:** пара добавлена в `_ALLOWED_CORE_CYCLES` (scripts/architecture_linter.py) с комментарием; `LANGUAGE_IMPORT_NODES` переведён на ленивую деривацию (кэш `_LANGUAGE_IMPORT_NODES_CACHE`, `__getattr__`), прямое обращение к карте внутри модуля заменено на `_get_language_import_nodes()`. Удалить из allowlist после выноса `IMPORT_NODE_MAP` в нейтральный модуль (не историю карт в parser) — тогда language_imports сможет импортировать parser односторонне. -- **Статус:** ✅ Fixed (allowed tech debt, deferred refactor; целевые 68 passed, architecture_linter 4/4 OK) -- **Дедлайн рефактора:** 2026-10-01 · **Owner:** ManSio +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** `architecture_linter` (Invariant 3) ╨┐╨╛╤Б╨╗╨╡ ╨┤╨╡╤А╨╕╨▓╨░╤Ж╨╕╨╕ `LANGUAGE_IMPORT_NODES` ╨╕╨╖ `CodeParser.IMPORT_NODE_MAP` (B4). +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** `src.core.language_imports` ╨╕╨╝╨┐╨╛╤А╤В╨╕╤А╤Г╨╡╤В `src.core.indexing.parser` (╨┤╨╗╤П ╨┤╨╡╤А╨╕╨▓╨░╤Ж╨╕╨╕ ╨║╨░╤А╤В╤Л), ╨░ `parser._extract_fallback_imports` ╨╕╨╝╨┐╨╛╤А╤В╨╕╤А╤Г╨╡╤В `language_imports` (fallback-╤А╨╡╨╢╨╕╨╝ 2). ╨б╤В╨░╤В╨╕╤З╨╡╤Б╨║╨╕ тАФ ╤Ж╨╕╨║╨╗; ╨▓ ╤А╨░╨╜╤В╨░╨╣╨╝╨╡ ╨╜╨╕ ╨╛╨┤╨╕╨╜ ╨╕╨╝╨┐╨╛╤А╤В ╨┐╤А╨╕ ╨╖╨░╨│╤А╤Г╨╖╨║╨╡ ╨╝╨╛╨┤╤Г╨╗╨╡╨╣ ╨╜╨╡ ╨▓╤Л╨┐╨╛╨╗╨╜╤П╨╡╤В╤Б╤П: parser ╨╕╨╝╨┐╨╛╤А╤В╨╕╤А╤Г╨╡╤В language_imports ╤В╨╛╨╗╤М╨║╨╛ ╨╗╨╛╨║╨░╨╗╤М╨╜╨╛ ╨▓ ╤Д╤Г╨╜╨║╤Ж╨╕╨╕; language_imports ╨╕╨╝╨┐╨╛╤А╤В╨╕╤А╤Г╨╡╤В parser ╤В╨╛╨╗╤М╨║╨╛ ╨╗╨╡╨╜╨╕╨▓╨╛ (module `__getattr__` тЖТ `_derive_language_import_nodes`, PEP 562) ╨┐╤А╨╕ ╨┐╨╡╤А╨▓╨╛╨╝ ╨╛╨▒╤А╨░╤Й╨╡╨╜╨╕╨╕ ╨║ `LANGUAGE_IMPORT_NODES`. +- **Fix:** ╨┐╨░╤А╨░ ╨┤╨╛╨▒╨░╨▓╨╗╨╡╨╜╨░ ╨▓ `_ALLOWED_CORE_CYCLES` (scripts/architecture_linter.py) ╤Б ╨║╨╛╨╝╨╝╨╡╨╜╤В╨░╤А╨╕╨╡╨╝; `LANGUAGE_IMPORT_NODES` ╨┐╨╡╤А╨╡╨▓╨╡╨┤╤С╨╜ ╨╜╨░ ╨╗╨╡╨╜╨╕╨▓╤Г╤О ╨┤╨╡╤А╨╕╨▓╨░╤Ж╨╕╤О (╨║╤Н╤И `_LANGUAGE_IMPORT_NODES_CACHE`, `__getattr__`), ╨┐╤А╤П╨╝╨╛╨╡ ╨╛╨▒╤А╨░╤Й╨╡╨╜╨╕╨╡ ╨║ ╨║╨░╤А╤В╨╡ ╨▓╨╜╤Г╤В╤А╨╕ ╨╝╨╛╨┤╤Г╨╗╤П ╨╖╨░╨╝╨╡╨╜╨╡╨╜╨╛ ╨╜╨░ `_get_language_import_nodes()`. ╨г╨┤╨░╨╗╨╕╤В╤М ╨╕╨╖ allowlist ╨┐╨╛╤Б╨╗╨╡ ╨▓╤Л╨╜╨╛╤Б╨░ `IMPORT_NODE_MAP` ╨▓ ╨╜╨╡╨╣╤В╤А╨░╨╗╤М╨╜╤Л╨╣ ╨╝╨╛╨┤╤Г╨╗╤М (╨╜╨╡ ╨╕╤Б╤В╨╛╤А╨╕╤О ╨║╨░╤А╤В ╨▓ parser) тАФ ╤В╨╛╨│╨┤╨░ language_imports ╤Б╨╝╨╛╨╢╨╡╤В ╨╕╨╝╨┐╨╛╤А╤В╨╕╤А╨╛╨▓╨░╤В╤М parser ╨╛╨┤╨╜╨╛╤Б╤В╨╛╤А╨╛╨╜╨╜╨╡. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed (allowed tech debt, deferred refactor; ╤Ж╨╡╨╗╨╡╨▓╤Л╨╡ 68 passed, architecture_linter 4/4 OK) +- **╨Ф╨╡╨┤╨╗╨░╨╣╨╜ ╤А╨╡╤Д╨░╨║╤В╨╛╤А╨░:** 2026-10-01 ┬╖ **Owner:** ManSio -## 2026-09-08 — B3: grammar-карты parser.py (imports/calls/assigns/conditions) внесены + живые фиксы +## 2026-09-08 тАФ B3: grammar-╨║╨░╤А╤В╤Л parser.py (imports/calls/assigns/conditions) ╨▓╨╜╨╡╤Б╨╡╨╜╤Л + ╨╢╨╕╨▓╤Л╨╡ ╤Д╨╕╨║╤Б╤Л -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed / **Root Cause и итог:** внесены из study 05 карты CALL_NODES/IMPORT_NODE_MAP/ASSIGNMENT_NODE_MAP/CONDITIONAL_NODE_MAP (пер-язычные) в `src/core/indexing/parser.py`. Живые tree-sit... -- **Статус:** автоматически синхронизировано +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** тЬЕ Fixed / **Root Cause ╨╕ ╨╕╤В╨╛╨│:** ╨▓╨╜╨╡╤Б╨╡╨╜╤Л ╨╕╨╖ study 05 ╨║╨░╤А╤В╤Л CALL_NODES/IMPORT_NODE_MAP/ASSIGNMENT_NODE_MAP/CONDITIONAL_NODE_MAP (╨┐╨╡╤А-╤П╨╖╤Л╤З╨╜╤Л╨╡) ╨▓ `src/core/indexing/parser.py`. ╨Ц╨╕╨▓╤Л╨╡ tree-sit... +- **╨б╤В╨░╤В╤Г╤Б:** ╨░╨▓╤В╨╛╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╕ ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╕╨╖╨╕╤А╨╛╨▓╨░╨╜╨╛ -## 2026-09-08 12:35 — B4: import-экстракция через language_imports (деривация карт + флаг-гейт) +## 2026-09-08 12:35 тАФ B4: import-╤Н╨║╤Б╤В╤А╨░╨║╤Ж╨╕╤П ╤З╨╡╤А╨╡╨╖ language_imports (╨┤╨╡╤А╨╕╨▓╨░╤Ж╨╕╤П ╨║╨░╤А╤В + ╤Д╨╗╨░╨│-╨│╨╡╨╣╤В) -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed / **Root Cause:** два источника node-типов импортов (parser.IMPORT_NODE_MAP и литерал LANGUAGE_IMPORT_NODES) расходились (kt/dart/php); ungated fallback-2 в мосте. +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** тЬЕ Fixed / **Root Cause:** ╨┤╨▓╨░ ╨╕╤Б╤В╨╛╤З╨╜╨╕╨║╨░ node-╤В╨╕╨┐╨╛╨▓ ╨╕╨╝╨┐╨╛╤А╤В╨╛╨▓ (parser.IMPORT_NODE_MAP ╨╕ ╨╗╨╕╤В╨╡╤А╨░╨╗ LANGUAGE_IMPORT_NODES) ╤А╨░╤Б╤Е╨╛╨┤╨╕╨╗╨╕╤Б╤М (kt/dart/php); ungated fallback-2 ╨▓ ╨╝╨╛╤Б╤В╨╡. **Fix:** LANGUAG... -- **Статус:** автоматически синхронизировано - -## 2026-09-09 19:35 - .h заголовки C не индексируются (SUPPORTED_EXTENSIONS без .h) - -- **Источник:** AutoCoder аудит/E-S1 live-проба 2026-09-09 (внешняя сессия, репо не изменялось до этой записи) -- **Описание:** **Status:** ✅ Fixed (2026-09-09, commit 0301fa93). CodeParser.SUPPORTED_EXTENSIONS/parsers не содержали ".h" (есть .hpp/.cxx/.cpp) - заголовки C-проектов выпадали из AST-индексации (импорты/вызовы/присваивания). Эмпирика E-S1 (shallow-клоны, кап 300 файлов/язык): curl - 65/300 файлов с явными #include дали 0 рёбер (преимущественно .h), dart-http .c-папка 0/9. Бонус-результат той же пробы: импорт-карты живые на 6 языках (java 0.867 / php 0.797 / c 0.680 / kotlin 0.853 / dart 0.940 / ruby 0.618), вызовы php 0.813 / ruby 0.562 / c 0.250 / dart 0.080 - синтетический дефект "вызовы PHP/Ruby/C/Dart" снят. -- **Fix:** ".h" добавлен в PARSE_EXTENSIONS (src/core/extensions.py) + C-парсер для ".h" (parser.py) + карты: env (".h":"c"), IMPORT_NODE_MAP (preproc_include), ASSIGNMENT_NODE_TYPES (init_declarator/assignment_expression), CONDITIONAL_NODE_TYPES (if/for/while/... как у ".c"). Пояснение: ".h" уже был в INDEX_EXTENSIONS (вектор индексировался), не хватало именно AST-слоя ⇒ map_lies. +1 тест (test_h_header_preproc_include). Повтор E-S1 пробы на curl (ожидание: map_lies .h -> ~0) — отложен, verified на уровнеunit-теста C-парсера. -- **Статус:** ✅ Fixed - -## 2026-09-13 12:00 - H4: свежесть снапшота dev.to KB — «gone» 97.5% без метрики (open) - -- **Источник:** EXPERIMENTS_LOG Exp 6 (2026-09-13), exp-37 portfolio lab -- **Описание:** **Status:** ⏳ Open (исследовательский хвост H4). При росте базы (13,519 статей/82,527 комментов) 97.5% хранимых комментариев — gone против live dev.to (live=2,030, gone=80,494), и нет метрики свежести снапшота. Локальная пересборка графа НЕ bottleneck (50,498 тредов за ~3с); узкое место — сетевая фаза capture (refresh own = 10м38с, 134 вызова dev.to API). Гипотеза: инкрементальный/осознанный refresh + быстрая метрика «доля gone» на снапшот вернут точность verify-on-read на частично свежем графе. -- **Fix:** не оптимизировать сборку графа; добавить метрику свежести + запланировать инкрементальный refresh. Эксперимент завершён (verdict confirmed), задача на оптимизацию — открыта. -- **Статус:** ⏳ - -## 2026-09-15 - [FEATURE] Bootstrap Pipeline: детерминированный импорт репозитория (по результатам Exp-38) - -- **Источник:** EXPERIMENTS_LOG Exp 7 (2026-09-15), exp-38 portfolio lab -- **Описание:** Эксперимент exp-38 подтвердил, что статический анализ не способен связать тесты с кодом (0% точности по именам; импорты дают только файловый уровень 77.9%). Dynamic trace через `sys.settrace` (pytest-плагин `experiments/bootstrap/dynamic_trace_plugin.py`) даёт **89.8%** точных тест→функция связей (1551/1727 тестов, 1212 уникальных src-функций) при оверхеде **+13.6%** (198.6s vs 174.8s, та же сессия). Точное имя-попадание внутри динамической выборки — всего 2.7%: ранжирование целевой функции требует дообогащения импортами файла/класса. 176 тестов (10.2%) не исполняют src-функций (моки/фикстуры). -- **Цель:** реализовать разовый плагин/конвейер первичности (bootstrap) для новых проектов. -- **Требуемые доработки:** **(1) Шаг 1 (Data structures):** фильтрация по сигналам `dataclass`/`NamedTuple` (46 чисто); исключить шум регекса `Table(` (90% — `open_table`, не SQL-схемы); pydantic/TypedDict в репо отсутствуют. **(2) Шаг 2 (Decorators enrichment):** обогатить индексатор графа генерацией `DECORATES`-рёбер для `@mcp_app.tool` (22 точки входа сейчас теряют связи; парсер имени `_decorator_name` срезает верно, узел `mcp_app.tool` в графе есть, tool-рёбер нет). **(3) Шаг 3 (Dynamic Trace pass):** интегрировать pytest-плагин как штатную команду `mscodebase bootstrap`. **(4) Шаг 4 (Git→ADR):** интегрировать существующий `intel_auto_collect_adrs` (уже работает). -- **Статус:** ⏳ зафиксировано, реализация не начата (iter-точка по решению владельца) -- **Прогресс Step A (2026-09-16):** **[A1 решён]** драйвер — `dynamic_trace_plugin.py` (sys.settrace, +13.6%); coverage.py — валидационный оракул (Exp 8: sysmon +19.96%, REFUTED). **[A2 реализован]** `src/core/bootstrap_tests.py` + `tests/test_bootstrap_tests.py`: TESTS-рёбра из `trace_result.json` в PropertyGraph. Live-прогон на реальной БД: создано **1595** Test-узлов (label=`Test`), переиспользовано 132 (индексаторные Function-узлы не обёртываются — `get_node` вместо `add_node`, т.к. последний перетирает label через ON CONFLICT), линковано **14985/15669** src-функций (95.7%), **16172** уникальных рёбер TESTS (UPSERT, идемпотентно), multi-match 781 (метод `Class.method` ↔ голый co_name). Не-матчи 684 = ``/`` (не имеют узлов). Red Team 5/5 закрытых (границы/дубли/label-перезапись/повторы/параметры). Осталось: команда `mscodebase bootstrap` (Шаг 3), // гейт CI для TESTS-рёбер. - - **[Exp 9, 2026-09-16 — Шаг B пересмотрен фактами]** - - **[Шаг 2 (DECORATES для @mcp_app.tool) — ЗАКРЫТ, уже реализован].** Живая БД содержит рёбра `DECORATES`: `mcp.tool`→14, `mcp_app.tool`→20 (tools_reg.py, dev_tools.py). Источник — `19378296` («vendor tree-sitter tags.scm + DECORATES/OVERRIDES edges»). Запись выше «tool-рёбер нет» устарела на момент фиксации. - - **[Шаг 1 (Data structures) — уточнён].** Exp 9 (Static Score Engine vs trace_result.json ground truth, v2 per-test): hit/recall/precision — L1 (прямые вызовы из тела теста): 88.4% / 30.3% / **68.0%** (avg 2.9 кандидата), L2 (токены имени): 17.7% / 3.8% / 12.1%, L3 (импорты): 91.6% / 72.0% / 21.8% (avg 41.4), union: 90.4% / 70.0% / 20.6%. Статика НЕ заменяет динамику (hit≠recall, recall union 70% < 100% dynamic на linked); L1 — точный якорь для ранжирования, L3 — широкий кандидат-пул, L2 — слабый (подтверждает Exp 7 «имя=0%»). Статика = fallback для динамически-пустых тестов (88/176 мок-тестов имеют стат. кандидатов) + pre-filter. Dynamic остаётся драйвером TESTS-edge. -**[Шаг 1 (Data structures) — РЕАЛИЗОВАН, 2026-09-17]** - - `src/core/bootstrap_entities.py` + `tests/test_bootstrap_entities.py` (8 unit-тестов): AST-детектор data structures по сигналам `@dataclass` (Name/Call/Attribute-формы) и базы `NamedTuple` (Name/Attribute). НЕ регекс — `Table(`-шум априори не виден (это вызовы, не классы). - - Live-прогон на реальном репо: **49 dataclass** (46 из графа + 3 наших bootstrap-классов: EntityShape/EntitiesBootstrapStats/TestsBootstrapStats — ещё не переиндексированы), **0 NamedTuple** (в репо отсутствуют), **open_table 12 call-сайтов** — не интерпретируются как сущности. Потерь против живого графа 0 (46 полностью покрыты детектором). pydantic/TypedDict/алиасы импортов (`NamedTuple as NT`) — вне скоупа (документировано в docstring). - - Роль (по Exp 9): статика — не замена динамики; детектор = fallback для динамически-пустых тестов + pre-filter ранжирования (вход для `mscodebase bootstrap`, Шаг 3). -- **Внешняя валидация на чужих Python-проектах (2026-09-17, anti-sleeveness):** - - Ревизия всех 18 репо `D:\Project\` (субагент): лучшие «чистые» кандидаты — `gemma_agent` (1102 py / 464 test_*.py / git), `456789/ARCLUX` (TS), `bench_projects` (black/httpbin/headroom). До этого детектор и TESTS-рёбра проверялись только на собственном репо. - - `bootstrap_entities.detect_entities` обобщается: **black(src)=16 dataclass / 2 NT / 73 classes**, **gemma_agent/core=65 dc / 228 classes**, gemma_agent/modules=1 dc, httpbin=0 (старый код без dataclass). Найдена слепота: детектор жёстко завязан на подкаталог `src/` — gemma_agent использует `core/`/`libraries/`/`modules/`, нужен явный `src_dir` (параметр уже есть). - - **[РЕШЕНО 2026-09-17]** Хардкод `src/` устранён: `resolve_src_root()` — детерминированный приоритет (явный `src_dir` → env `MSCODEBASE_BOOTSTRAP_SRC_DIR` → известные раскладки `src/lib/core/libraries/modules` → каталог с именем проекта → статистический fallback с исключением `tests/docs/venv`). Валидация: MSCodeBase→src, gemma_agent→core (ранее требовал источник), black→src, httpbin→httpbin. Результаты совпадают с ручным `src_dir` (1:1). Тесты 14/14, в т.ч. 6 новых (core-layout/имя-проекта/статистика/env/приоритет-env/пустой-прогон). Red Team 3/3 (venv-tests не захватываются статистикой, битый env не ломает, относительный src_dir работает). - - **[Шаг 3 (Dynamic Trace command) — РЕАЛИЗОВАН, 2026-09-18]** - - `git mv experiments/bootstrap/dynamic_trace_plugin.py src/core/bootstrap_trace_plugin.py` — плагин штатный; импорт `-p src.core.bootstrap_trace_plugin`. -- `src/core/bootstrap_pipeline.py` (оркестратор: resolve_src_root → detect_entities → pytest subprocess §5.16-safe → index_src_functions → build_tests_edges) + `bootstrap_tool.py` (`bootstrap_pipeline`, MCP+CLI). Подводные камни — AGENT_DIARY 2026-09-18: PYTHONPATH только по авто-детекту `_plugin_importable` (namespace shadowing `src`-пакета), BOM-guard `utf-8-sig`. - - Тесты: `tests/test_bootstrap_pipeline.py` (5 интеграц., без моков) + 3 на `index_src_functions`; 28/28 green + полный suite passed. Клиент параметризован по env (`TRACE_SRC_ROOT`/`TRACE_OUT`) → чужие проекты: gemma_agent 2737/2882 (95.0%) тестов имеют ≥1 src-функцию; black скомпилирован в `.pyd` → sys.settrace не ловит нативные кадры (fallback на статику Exp 9 обязателен). -- **Веб-исследование и audit «гиблых мест» (2026-09-15, всё ПРОВЕРЕНО эмпирически):** (1) **sysmon+dynamic_context — ОПРОВЕРГНУТА**: верные контексты даёт pytest-коллекция, ручной `switch_context` → пустые `['']` (coverage.py 7.14.1); (2) **контексты ≈3-7% — НЕ воспроизвелось**: Exp 8 (2026-09-16) overhead **+19.96%** (221.78 vs 184.88s) > нашего sys.settrace (+13.6%) → штатный драйвер Шага 3 = `dynamic_trace_plugin.py`, coverage остаётся валидационным оракулом (контексты качественные: 1548/1549, 75.5% src-строк привязаны); (3) **Tarantula — Exp 7b**: rank≤3 у 22.6% тестов (далеко от 60-70%), НО precision низких рангов высока (все rank1-3 верны) → аннотация confidence (~16%), не селектор; TESTS-ребро строится из полной трассы; (4) **mutation-testing как ground truth — дорого/хрупко** (FSE'20, Google 33M; флаки раздувают score); (5) **pytest-testmon — не копируем** (line-based, сужение рерана ≠ граф-ребро TESTS для LLM-контекста); (6) **dev.to-кросс-чек**: «TRUE Coverage» (Dawson, 2026-07-22) подтверждает плато статики и шум shared-utils (наш safe_mkdir/get_data_root кейс 1:1; CI 43min→4min, precision 15%→95%); «Empirical Failure Modes» (Arthur, 2026-07-31) — Pass-Through Test Mirage (наш «фантомный код»), Python 3.14 sys.monitoring reachability = наш бэкенд, AST orphan-detection = наш Шаг 1; **ниша TESTS-рёбер для LLM-контекста ими не занята** (per-test coverage используется только для selection/rejection); (7) edge-case (Gemini): без тестов → статика; бинарники → Docker+microtrace; async → OpenTelemetry по trace_id. - -## 2026-09-11 — Burst-rename: fail-closed VOR отзывает 100% при ONE rename-sweep (ответ Statewave на dev.to) - -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** Closed (эксперименты, ответ опубликован) -**Root Cause:** VOR (ADR-0003) проверяет ПУТЬ-якоря против текущего HEAD. Rename/move = старый путь отсутствует = SILENT_ABSENCE = отзыв, хотя файл... -- **Статус:** автоматически синхронизировано - -## 2026-09-11 — VOR read-path fix (PR #34) + «8-минутный коммит» = НЕ баг (решение владельца) - -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ PR #34 создан, hooks green; скорость тестов — осознанное решение, код НЕ менялся. -**Root Cause:** (1) read-path VOR ре-сканировал prose тела ADR через `_PATH_RE`, хотя явные `data.anchor... -- **Статус:** автоматически синхронизировано - -## 2026-09-10 — Exp 1 (Catch-up Rate) + Exp 3 (HEAD polling): VOR масштабирование и внешний дрифт - -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fix (замеры, кода не менялось). **Root Cause (KNOW ISSUES «Lazy-only верификация»):** вопрос, успевает ли VOR проверить ACTIVE-узлы в рамках budget_ms=50 (read-path) / 250 (background id... -- **Статус:** автоматически синхронизировано - -## 2026-09-10 — Exp 2 (Agent Behavior) + Exp 4 (Fail-Closed Freshness Gate) - -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed. **Root Cause (Exhibit #23, 2026-09-09):** inform-the-agent approach insufficient — agent can ignore STALE alerts; PlanFence 30/30 failures confirms action-validation unreliable; s... -- **Статус:** автоматически синхронизировано - -## 2026-09-13 — H4: agent-memory lifecycle в масштабе dev.to KB — бутылочное горлышко = сетевой capture, не граф - -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** Fixed (эксперимент подтверждён; сопровождение задачи closed) -**Root Cause:** при росте базы 3,989 → 13,519 статей (3.4x), refresh own занял 10м38с на 13.5k статей/82.5k комментов (134 сете... -- **Статус:** автоматически синхронизировано +- **╨б╤В╨░╤В╤Г╤Б:** ╨░╨▓╤В╨╛╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╕ ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╕╨╖╨╕╤А╨╛╨▓╨░╨╜╨╛ + +## 2026-09-09 19:35 - .h ╨╖╨░╨│╨╛╨╗╨╛╨▓╨║╨╕ C ╨╜╨╡ ╨╕╨╜╨┤╨╡╨║╤Б╨╕╤А╤Г╤О╤В╤Б╤П (SUPPORTED_EXTENSIONS ╨▒╨╡╨╖ .h) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AutoCoder ╨░╤Г╨┤╨╕╤В/E-S1 live-╨┐╤А╨╛╨▒╨░ 2026-09-09 (╨▓╨╜╨╡╤И╨╜╤П╤П ╤Б╨╡╤Б╤Б╨╕╤П, ╤А╨╡╨┐╨╛ ╨╜╨╡ ╨╕╨╖╨╝╨╡╨╜╤П╨╗╨╛╤Б╤М ╨┤╨╛ ╤Н╤В╨╛╨╣ ╨╖╨░╨┐╨╕╤Б╨╕) +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** тЬЕ Fixed (2026-09-09, commit 0301fa93). CodeParser.SUPPORTED_EXTENSIONS/parsers ╨╜╨╡ ╤Б╨╛╨┤╨╡╤А╨╢╨░╨╗╨╕ ".h" (╨╡╤Б╤В╤М .hpp/.cxx/.cpp) - ╨╖╨░╨│╨╛╨╗╨╛╨▓╨║╨╕ C-╨┐╤А╨╛╨╡╨║╤В╨╛╨▓ ╨▓╤Л╨┐╨░╨┤╨░╨╗╨╕ ╨╕╨╖ AST-╨╕╨╜╨┤╨╡╨║╤Б╨░╤Ж╨╕╨╕ (╨╕╨╝╨┐╨╛╤А╤В╤Л/╨▓╤Л╨╖╨╛╨▓╤Л/╨┐╤А╨╕╤Б╨▓╨░╨╕╨▓╨░╨╜╨╕╤П). ╨н╨╝╨┐╨╕╤А╨╕╨║╨░ E-S1 (shallow-╨║╨╗╨╛╨╜╤Л, ╨║╨░╨┐ 300 ╤Д╨░╨╣╨╗╨╛╨▓/╤П╨╖╤Л╨║): curl - 65/300 ╤Д╨░╨╣╨╗╨╛╨▓ ╤Б ╤П╨▓╨╜╤Л╨╝╨╕ #include ╨┤╨░╨╗╨╕ 0 ╤А╤С╨▒╨╡╤А (╨┐╤А╨╡╨╕╨╝╤Г╤Й╨╡╤Б╤В╨▓╨╡╨╜╨╜╨╛ .h), dart-http .c-╨┐╨░╨┐╨║╨░ 0/9. ╨С╨╛╨╜╤Г╤Б-╤А╨╡╨╖╤Г╨╗╤М╤В╨░╤В ╤В╨╛╨╣ ╨╢╨╡ ╨┐╤А╨╛╨▒╤Л: ╨╕╨╝╨┐╨╛╤А╤В-╨║╨░╤А╤В╤Л ╨╢╨╕╨▓╤Л╨╡ ╨╜╨░ 6 ╤П╨╖╤Л╨║╨░╤Е (java 0.867 / php 0.797 / c 0.680 / kotlin 0.853 / dart 0.940 / ruby 0.618), ╨▓╤Л╨╖╨╛╨▓╤Л php 0.813 / ruby 0.562 / c 0.250 / dart 0.080 - ╤Б╨╕╨╜╤В╨╡╤В╨╕╤З╨╡╤Б╨║╨╕╨╣ ╨┤╨╡╤Д╨╡╨║╤В "╨▓╤Л╨╖╨╛╨▓╤Л PHP/Ruby/C/Dart" ╤Б╨╜╤П╤В. +- **Fix:** ".h" ╨┤╨╛╨▒╨░╨▓╨╗╨╡╨╜ ╨▓ PARSE_EXTENSIONS (src/core/extensions.py) + C-╨┐╨░╤А╤Б╨╡╤А ╨┤╨╗╤П ".h" (parser.py) + ╨║╨░╤А╤В╤Л: env (".h":"c"), IMPORT_NODE_MAP (preproc_include), ASSIGNMENT_NODE_TYPES (init_declarator/assignment_expression), CONDITIONAL_NODE_TYPES (if/for/while/... ╨║╨░╨║ ╤Г ".c"). ╨Я╨╛╤П╤Б╨╜╨╡╨╜╨╕╨╡: ".h" ╤Г╨╢╨╡ ╨▒╤Л╨╗ ╨▓ INDEX_EXTENSIONS (╨▓╨╡╨║╤В╨╛╤А ╨╕╨╜╨┤╨╡╨║╤Б╨╕╤А╨╛╨▓╨░╨╗╤Б╤П), ╨╜╨╡ ╤Е╨▓╨░╤В╨░╨╗╨╛ ╨╕╨╝╨╡╨╜╨╜╨╛ AST-╤Б╨╗╨╛╤П тЗТ map_lies. +1 ╤В╨╡╤Б╤В (test_h_header_preproc_include). ╨Я╨╛╨▓╤В╨╛╤А E-S1 ╨┐╤А╨╛╨▒╤Л ╨╜╨░ curl (╨╛╨╢╨╕╨┤╨░╨╜╨╕╨╡: map_lies .h -> ~0) тАФ ╨╛╤В╨╗╨╛╨╢╨╡╨╜, verified ╨╜╨░ ╤Г╤А╨╛╨▓╨╜╨╡unit-╤В╨╡╤Б╤В╨░ C-╨┐╨░╤А╤Б╨╡╤А╨░. +- **╨б╤В╨░╤В╤Г╤Б:** тЬЕ Fixed + +## 2026-09-13 12:00 - H4: ╤Б╨▓╨╡╨╢╨╡╤Б╤В╤М ╤Б╨╜╨░╨┐╤И╨╛╤В╨░ dev.to KB тАФ ┬лgone┬╗ 97.5% ╨▒╨╡╨╖ ╨╝╨╡╤В╤А╨╕╨║╨╕ (open) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** EXPERIMENTS_LOG Exp 6 (2026-09-13), exp-37 portfolio lab +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** тП│ Open (╨╕╤Б╤Б╨╗╨╡╨┤╨╛╨▓╨░╤В╨╡╨╗╤М╤Б╨║╨╕╨╣ ╤Е╨▓╨╛╤Б╤В H4). ╨Я╤А╨╕ ╤А╨╛╤Б╤В╨╡ ╨▒╨░╨╖╤Л (13,519 ╤Б╤В╨░╤В╨╡╨╣/82,527 ╨║╨╛╨╝╨╝╨╡╨╜╤В╨╛╨▓) 97.5% ╤Е╤А╨░╨╜╨╕╨╝╤Л╤Е ╨║╨╛╨╝╨╝╨╡╨╜╤В╨░╤А╨╕╨╡╨▓ тАФ gone ╨┐╤А╨╛╤В╨╕╨▓ live dev.to (live=2,030, gone=80,494), ╨╕ ╨╜╨╡╤В ╨╝╨╡╤В╤А╨╕╨║╨╕ ╤Б╨▓╨╡╨╢╨╡╤Б╤В╨╕ ╤Б╨╜╨░╨┐╤И╨╛╤В╨░. ╨Ы╨╛╨║╨░╨╗╤М╨╜╨░╤П ╨┐╨╡╤А╨╡╤Б╨▒╨╛╤А╨║╨░ ╨│╤А╨░╤Д╨░ ╨Э╨Х bottleneck (50,498 ╤В╤А╨╡╨┤╨╛╨▓ ╨╖╨░ ~3╤Б); ╤Г╨╖╨║╨╛╨╡ ╨╝╨╡╤Б╤В╨╛ тАФ ╤Б╨╡╤В╨╡╨▓╨░╤П ╤Д╨░╨╖╨░ capture (refresh own = 10╨╝38╤Б, 134 ╨▓╤Л╨╖╨╛╨▓╨░ dev.to API). ╨У╨╕╨┐╨╛╤В╨╡╨╖╨░: ╨╕╨╜╨║╤А╨╡╨╝╨╡╨╜╤В╨░╨╗╤М╨╜╤Л╨╣/╨╛╤Б╨╛╨╖╨╜╨░╨╜╨╜╤Л╨╣ refresh + ╨▒╤Л╤Б╤В╤А╨░╤П ╨╝╨╡╤В╤А╨╕╨║╨░ ┬л╨┤╨╛╨╗╤П gone┬╗ ╨╜╨░ ╤Б╨╜╨░╨┐╤И╨╛╤В ╨▓╨╡╤А╨╜╤Г╤В ╤В╨╛╤З╨╜╨╛╤Б╤В╤М verify-on-read ╨╜╨░ ╤З╨░╤Б╤В╨╕╤З╨╜╨╛ ╤Б╨▓╨╡╨╢╨╡╨╝ ╨│╤А╨░╤Д╨╡. +- **Fix:** ╨╜╨╡ ╨╛╨┐╤В╨╕╨╝╨╕╨╖╨╕╤А╨╛╨▓╨░╤В╤М ╤Б╨▒╨╛╤А╨║╤Г ╨│╤А╨░╤Д╨░; ╨┤╨╛╨▒╨░╨▓╨╕╤В╤М ╨╝╨╡╤В╤А╨╕╨║╤Г ╤Б╨▓╨╡╨╢╨╡╤Б╤В╨╕ + ╨╖╨░╨┐╨╗╨░╨╜╨╕╤А╨╛╨▓╨░╤В╤М ╨╕╨╜╨║╤А╨╡╨╝╨╡╨╜╤В╨░╨╗╤М╨╜╤Л╨╣ refresh. ╨н╨║╤Б╨┐╨╡╤А╨╕╨╝╨╡╨╜╤В ╨╖╨░╨▓╨╡╤А╤И╤С╨╜ (verdict confirmed), ╨╖╨░╨┤╨░╤З╨░ ╨╜╨░ ╨╛╨┐╤В╨╕╨╝╨╕╨╖╨░╤Ж╨╕╤О тАФ ╨╛╤В╨║╤А╤Л╤В╨░. +- **╨б╤В╨░╤В╤Г╤Б:** тП│ + +## 2026-09-15 - [FEATURE] Bootstrap Pipeline: ╨┤╨╡╤В╨╡╤А╨╝╨╕╨╜╨╕╤А╨╛╨▓╨░╨╜╨╜╤Л╨╣ ╨╕╨╝╨┐╨╛╤А╤В ╤А╨╡╨┐╨╛╨╖╨╕╤В╨╛╤А╨╕╤П (╨┐╨╛ ╤А╨╡╨╖╤Г╨╗╤М╤В╨░╤В╨░╨╝ Exp-38) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** EXPERIMENTS_LOG Exp 7 (2026-09-15), exp-38 portfolio lab +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** ╨н╨║╤Б╨┐╨╡╤А╨╕╨╝╨╡╨╜╤В exp-38 ╨┐╨╛╨┤╤В╨▓╨╡╤А╨┤╨╕╨╗, ╤З╤В╨╛ ╤Б╤В╨░╤В╨╕╤З╨╡╤Б╨║╨╕╨╣ ╨░╨╜╨░╨╗╨╕╨╖ ╨╜╨╡ ╤Б╨┐╨╛╤Б╨╛╨▒╨╡╨╜ ╤Б╨▓╤П╨╖╨░╤В╤М ╤В╨╡╤Б╤В╤Л ╤Б ╨║╨╛╨┤╨╛╨╝ (0% ╤В╨╛╤З╨╜╨╛╤Б╤В╨╕ ╨┐╨╛ ╨╕╨╝╨╡╨╜╨░╨╝; ╨╕╨╝╨┐╨╛╤А╤В╤Л ╨┤╨░╤О╤В ╤В╨╛╨╗╤М╨║╨╛ ╤Д╨░╨╣╨╗╨╛╨▓╤Л╨╣ ╤Г╤А╨╛╨▓╨╡╨╜╤М 77.9%). Dynamic trace ╤З╨╡╤А╨╡╨╖ `sys.settrace` (pytest-╨┐╨╗╨░╨│╨╕╨╜ `experiments/bootstrap/dynamic_trace_plugin.py`) ╨┤╨░╤С╤В **89.8%** ╤В╨╛╤З╨╜╤Л╤Е ╤В╨╡╤Б╤ВтЖТ╤Д╤Г╨╜╨║╤Ж╨╕╤П ╤Б╨▓╤П╨╖╨╡╨╣ (1551/1727 ╤В╨╡╤Б╤В╨╛╨▓, 1212 ╤Г╨╜╨╕╨║╨░╨╗╤М╨╜╤Л╤Е src-╤Д╤Г╨╜╨║╤Ж╨╕╨╣) ╨┐╤А╨╕ ╨╛╨▓╨╡╤А╤Е╨╡╨┤╨╡ **+13.6%** (198.6s vs 174.8s, ╤В╨░ ╨╢╨╡ ╤Б╨╡╤Б╤Б╨╕╤П). ╨в╨╛╤З╨╜╨╛╨╡ ╨╕╨╝╤П-╨┐╨╛╨┐╨░╨┤╨░╨╜╨╕╨╡ ╨▓╨╜╤Г╤В╤А╨╕ ╨┤╨╕╨╜╨░╨╝╨╕╤З╨╡╤Б╨║╨╛╨╣ ╨▓╤Л╨▒╨╛╤А╨║╨╕ тАФ ╨▓╤Б╨╡╨│╨╛ 2.7%: ╤А╨░╨╜╨╢╨╕╤А╨╛╨▓╨░╨╜╨╕╨╡ ╤Ж╨╡╨╗╨╡╨▓╨╛╨╣ ╤Д╤Г╨╜╨║╤Ж╨╕╨╕ ╤В╤А╨╡╨▒╤Г╨╡╤В ╨┤╨╛╨╛╨▒╨╛╨│╨░╤Й╨╡╨╜╨╕╤П ╨╕╨╝╨┐╨╛╤А╤В╨░╨╝╨╕ ╤Д╨░╨╣╨╗╨░/╨║╨╗╨░╤Б╤Б╨░. 176 ╤В╨╡╤Б╤В╨╛╨▓ (10.2%) ╨╜╨╡ ╨╕╤Б╨┐╨╛╨╗╨╜╤П╤О╤В src-╤Д╤Г╨╜╨║╤Ж╨╕╨╣ (╨╝╨╛╨║╨╕/╤Д╨╕╨║╤Б╤В╤Г╤А╤Л). +- **╨ж╨╡╨╗╤М:** ╤А╨╡╨░╨╗╨╕╨╖╨╛╨▓╨░╤В╤М ╤А╨░╨╖╨╛╨▓╤Л╨╣ ╨┐╨╗╨░╨│╨╕╨╜/╨║╨╛╨╜╨▓╨╡╨╣╨╡╤А ╨┐╨╡╤А╨▓╨╕╤З╨╜╨╛╤Б╤В╨╕ (bootstrap) ╨┤╨╗╤П ╨╜╨╛╨▓╤Л╤Е ╨┐╤А╨╛╨╡╨║╤В╨╛╨▓. +- **╨в╤А╨╡╨▒╤Г╨╡╨╝╤Л╨╡ ╨┤╨╛╤А╨░╨▒╨╛╤В╨║╨╕:** **(1) ╨и╨░╨│ 1 (Data structures):** ╤Д╨╕╨╗╤М╤В╤А╨░╤Ж╨╕╤П ╨┐╨╛ ╤Б╨╕╨│╨╜╨░╨╗╨░╨╝ `dataclass`/`NamedTuple` (46 ╤З╨╕╤Б╤В╨╛); ╨╕╤Б╨║╨╗╤О╤З╨╕╤В╤М ╤И╤Г╨╝ ╤А╨╡╨│╨╡╨║╤Б╨░ `Table(` (90% тАФ `open_table`, ╨╜╨╡ SQL-╤Б╤Е╨╡╨╝╤Л); pydantic/TypedDict ╨▓ ╤А╨╡╨┐╨╛ ╨╛╤В╤Б╤Г╤В╤Б╤В╨▓╤Г╤О╤В. **(2) ╨и╨░╨│ 2 (Decorators enrichment):** ╨╛╨▒╨╛╨│╨░╤В╨╕╤В╤М ╨╕╨╜╨┤╨╡╨║╤Б╨░╤В╨╛╤А ╨│╤А╨░╤Д╨░ ╨│╨╡╨╜╨╡╤А╨░╤Ж╨╕╨╡╨╣ `DECORATES`-╤А╤С╨▒╨╡╤А ╨┤╨╗╤П `@mcp_app.tool` (22 ╤В╨╛╤З╨║╨╕ ╨▓╤Е╨╛╨┤╨░ ╤Б╨╡╨╣╤З╨░╤Б ╤В╨╡╤А╤П╤О╤В ╤Б╨▓╤П╨╖╨╕; ╨┐╨░╤А╤Б╨╡╤А ╨╕╨╝╨╡╨╜╨╕ `_decorator_name` ╤Б╤А╨╡╨╖╨░╨╡╤В ╨▓╨╡╤А╨╜╨╛, ╤Г╨╖╨╡╨╗ `mcp_app.tool` ╨▓ ╨│╤А╨░╤Д╨╡ ╨╡╤Б╤В╤М, tool-╤А╤С╨▒╨╡╤А ╨╜╨╡╤В). **(3) ╨и╨░╨│ 3 (Dynamic Trace pass):** ╨╕╨╜╤В╨╡╨│╤А╨╕╤А╨╛╨▓╨░╤В╤М pytest-╨┐╨╗╨░╨│╨╕╨╜ ╨║╨░╨║ ╤И╤В╨░╤В╨╜╤Г╤О ╨║╨╛╨╝╨░╨╜╨┤╤Г `mscodebase bootstrap`. **(4) ╨и╨░╨│ 4 (GitтЖТADR):** ╨╕╨╜╤В╨╡╨│╤А╨╕╤А╨╛╨▓╨░╤В╤М ╤Б╤Г╤Й╨╡╤Б╤В╨▓╤Г╤О╤Й╨╕╨╣ `intel_auto_collect_adrs` (╤Г╨╢╨╡ ╤А╨░╨▒╨╛╤В╨░╨╡╤В). +- **╨б╤В╨░╤В╤Г╤Б:** тП│ ╨╖╨░╤Д╨╕╨║╤Б╨╕╤А╨╛╨▓╨░╨╜╨╛, ╤А╨╡╨░╨╗╨╕╨╖╨░╤Ж╨╕╤П ╨╜╨╡ ╨╜╨░╤З╨░╤В╨░ (iter-╤В╨╛╤З╨║╨░ ╨┐╨╛ ╤А╨╡╤И╨╡╨╜╨╕╤О ╨▓╨╗╨░╨┤╨╡╨╗╤М╤Ж╨░) +- **╨Я╤А╨╛╨│╤А╨╡╤Б╤Б Step A (2026-09-16):** **[A1 ╤А╨╡╤И╤С╨╜]** ╨┤╤А╨░╨╣╨▓╨╡╤А тАФ `dynamic_trace_plugin.py` (sys.settrace, +13.6%); coverage.py тАФ ╨▓╨░╨╗╨╕╨┤╨░╤Ж╨╕╨╛╨╜╨╜╤Л╨╣ ╨╛╤А╨░╨║╤Г╨╗ (Exp 8: sysmon +19.96%, REFUTED). **[A2 ╤А╨╡╨░╨╗╨╕╨╖╨╛╨▓╨░╨╜]** `src/core/bootstrap_tests.py` + `tests/test_bootstrap_tests.py`: TESTS-╤А╤С╨▒╤А╨░ ╨╕╨╖ `trace_result.json` ╨▓ PropertyGraph. Live-╨┐╤А╨╛╨│╨╛╨╜ ╨╜╨░ ╤А╨╡╨░╨╗╤М╨╜╨╛╨╣ ╨С╨Ф: ╤Б╨╛╨╖╨┤╨░╨╜╨╛ **1595** Test-╤Г╨╖╨╗╨╛╨▓ (label=`Test`), ╨┐╨╡╤А╨╡╨╕╤Б╨┐╨╛╨╗╤М╨╖╨╛╨▓╨░╨╜╨╛ 132 (╨╕╨╜╨┤╨╡╨║╤Б╨░╤В╨╛╤А╨╜╤Л╨╡ Function-╤Г╨╖╨╗╤Л ╨╜╨╡ ╨╛╨▒╤С╤А╤В╤Л╨▓╨░╤О╤В╤Б╤П тАФ `get_node` ╨▓╨╝╨╡╤Б╤В╨╛ `add_node`, ╤В.╨║. ╨┐╨╛╤Б╨╗╨╡╨┤╨╜╨╕╨╣ ╨┐╨╡╤А╨╡╤В╨╕╤А╨░╨╡╤В label ╤З╨╡╤А╨╡╨╖ ON CONFLICT), ╨╗╨╕╨╜╨║╨╛╨▓╨░╨╜╨╛ **14985/15669** src-╤Д╤Г╨╜╨║╤Ж╨╕╨╣ (95.7%), **16172** ╤Г╨╜╨╕╨║╨░╨╗╤М╨╜╤Л╤Е ╤А╤С╨▒╨╡╤А TESTS (UPSERT, ╨╕╨┤╨╡╨╝╨┐╨╛╤В╨╡╨╜╤В╨╜╨╛), multi-match 781 (╨╝╨╡╤В╨╛╨┤ `Class.method` тЖФ ╨│╨╛╨╗╤Л╨╣ co_name). ╨Э╨╡-╨╝╨░╤В╤З╨╕ 684 = ``/`` (╨╜╨╡ ╨╕╨╝╨╡╤О╤В ╤Г╨╖╨╗╨╛╨▓). Red Team 5/5 ╨╖╨░╨║╤А╤Л╤В╤Л╤Е (╨│╤А╨░╨╜╨╕╤Ж╤Л/╨┤╤Г╨▒╨╗╨╕/label-╨┐╨╡╤А╨╡╨╖╨░╨┐╨╕╤Б╤М/╨┐╨╛╨▓╤В╨╛╤А╤Л/╨┐╨░╤А╨░╨╝╨╡╤В╤А╤Л). ╨Ю╤Б╤В╨░╨╗╨╛╤Б╤М: ╨║╨╛╨╝╨░╨╜╨┤╨░ `mscodebase bootstrap` (╨и╨░╨│ 3), // ╨│╨╡╨╣╤В CI ╨┤╨╗╤П TESTS-╤А╤С╨▒╨╡╤А. + + **[Exp 9, 2026-09-16 тАФ ╨и╨░╨│ B ╨┐╨╡╤А╨╡╤Б╨╝╨╛╤В╤А╨╡╨╜ ╤Д╨░╨║╤В╨░╨╝╨╕]** + - **[╨и╨░╨│ 2 (DECORATES ╨┤╨╗╤П @mcp_app.tool) тАФ ╨Ч╨Р╨Ъ╨а╨л╨в, ╤Г╨╢╨╡ ╤А╨╡╨░╨╗╨╕╨╖╨╛╨▓╨░╨╜].** ╨Ц╨╕╨▓╨░╤П ╨С╨Ф ╤Б╨╛╨┤╨╡╤А╨╢╨╕╤В ╤А╤С╨▒╤А╨░ `DECORATES`: `mcp.tool`тЖТ14, `mcp_app.tool`тЖТ20 (tools_reg.py, dev_tools.py). ╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║ тАФ `19378296` (┬лvendor tree-sitter tags.scm + DECORATES/OVERRIDES edges┬╗). ╨Ч╨░╨┐╨╕╤Б╤М ╨▓╤Л╤И╨╡ ┬лtool-╤А╤С╨▒╨╡╤А ╨╜╨╡╤В┬╗ ╤Г╤Б╤В╨░╤А╨╡╨╗╨░ ╨╜╨░ ╨╝╨╛╨╝╨╡╨╜╤В ╤Д╨╕╨║╤Б╨░╤Ж╨╕╨╕. + - **[╨и╨░╨│ 1 (Data structures) тАФ ╤Г╤В╨╛╤З╨╜╤С╨╜].** Exp 9 (Static Score Engine vs trace_result.json ground truth, v2 per-test): hit/recall/precision тАФ L1 (╨┐╤А╤П╨╝╤Л╨╡ ╨▓╤Л╨╖╨╛╨▓╤Л ╨╕╨╖ ╤В╨╡╨╗╨░ ╤В╨╡╤Б╤В╨░): 88.4% / 30.3% / **68.0%** (avg 2.9 ╨║╨░╨╜╨┤╨╕╨┤╨░╤В╨░), L2 (╤В╨╛╨║╨╡╨╜╤Л ╨╕╨╝╨╡╨╜╨╕): 17.7% / 3.8% / 12.1%, L3 (╨╕╨╝╨┐╨╛╤А╤В╤Л): 91.6% / 72.0% / 21.8% (avg 41.4), union: 90.4% / 70.0% / 20.6%. ╨б╤В╨░╤В╨╕╨║╨░ ╨Э╨Х ╨╖╨░╨╝╨╡╨╜╤П╨╡╤В ╨┤╨╕╨╜╨░╨╝╨╕╨║╤Г (hitтЙаrecall, recall union 70% < 100% dynamic ╨╜╨░ linked); L1 тАФ ╤В╨╛╤З╨╜╤Л╨╣ ╤П╨║╨╛╤А╤М ╨┤╨╗╤П ╤А╨░╨╜╨╢╨╕╤А╨╛╨▓╨░╨╜╨╕╤П, L3 тАФ ╤И╨╕╤А╨╛╨║╨╕╨╣ ╨║╨░╨╜╨┤╨╕╨┤╨░╤В-╨┐╤Г╨╗, L2 тАФ ╤Б╨╗╨░╨▒╤Л╨╣ (╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨░╨╡╤В Exp 7 ┬л╨╕╨╝╤П=0%┬╗). ╨б╤В╨░╤В╨╕╨║╨░ = fallback ╨┤╨╗╤П ╨┤╨╕╨╜╨░╨╝╨╕╤З╨╡╤Б╨║╨╕-╨┐╤Г╤Б╤В╤Л╤Е ╤В╨╡╤Б╤В╨╛╨▓ (88/176 ╨╝╨╛╨║-╤В╨╡╤Б╤В╨╛╨▓ ╨╕╨╝╨╡╤О╤В ╤Б╤В╨░╤В. ╨║╨░╨╜╨┤╨╕╨┤╨░╤В╨╛╨▓) + pre-filter. Dynamic ╨╛╤Б╤В╨░╤С╤В╤Б╤П ╨┤╤А╨░╨╣╨▓╨╡╤А╨╛╨╝ TESTS-edge. +**[╨и╨░╨│ 1 (Data structures) тАФ ╨а╨Х╨Р╨Ы╨Ш╨Ч╨Ю╨Т╨Р╨Э, 2026-09-17]** + - `src/core/bootstrap_entities.py` + `tests/test_bootstrap_entities.py` (8 unit-╤В╨╡╤Б╤В╨╛╨▓): AST-╨┤╨╡╤В╨╡╨║╤В╨╛╤А data structures ╨┐╨╛ ╤Б╨╕╨│╨╜╨░╨╗╨░╨╝ `@dataclass` (Name/Call/Attribute-╤Д╨╛╤А╨╝╤Л) ╨╕ ╨▒╨░╨╖╤Л `NamedTuple` (Name/Attribute). ╨Э╨Х ╤А╨╡╨│╨╡╨║╤Б тАФ `Table(`-╤И╤Г╨╝ ╨░╨┐╤А╨╕╨╛╤А╨╕ ╨╜╨╡ ╨▓╨╕╨┤╨╡╨╜ (╤Н╤В╨╛ ╨▓╤Л╨╖╨╛╨▓╤Л, ╨╜╨╡ ╨║╨╗╨░╤Б╤Б╤Л). + - Live-╨┐╤А╨╛╨│╨╛╨╜ ╨╜╨░ ╤А╨╡╨░╨╗╤М╨╜╨╛╨╝ ╤А╨╡╨┐╨╛: **49 dataclass** (46 ╨╕╨╖ ╨│╤А╨░╤Д╨░ + 3 ╨╜╨░╤И╨╕╤Е bootstrap-╨║╨╗╨░╤Б╤Б╨╛╨▓: EntityShape/EntitiesBootstrapStats/TestsBootstrapStats тАФ ╨╡╤Й╤С ╨╜╨╡ ╨┐╨╡╤А╨╡╨╕╨╜╨┤╨╡╨║╤Б╨╕╤А╨╛╨▓╨░╨╜╤Л), **0 NamedTuple** (╨▓ ╤А╨╡╨┐╨╛ ╨╛╤В╤Б╤Г╤В╤Б╤В╨▓╤Г╤О╤В), **open_table 12 call-╤Б╨░╨╣╤В╨╛╨▓** тАФ ╨╜╨╡ ╨╕╨╜╤В╨╡╤А╨┐╤А╨╡╤В╨╕╤А╤Г╤О╤В╤Б╤П ╨║╨░╨║ ╤Б╤Г╤Й╨╜╨╛╤Б╤В╨╕. ╨Я╨╛╤В╨╡╤А╤М ╨┐╤А╨╛╤В╨╕╨▓ ╨╢╨╕╨▓╨╛╨│╨╛ ╨│╤А╨░╤Д╨░ 0 (46 ╨┐╨╛╨╗╨╜╨╛╤Б╤В╤М╤О ╨┐╨╛╨║╤А╤Л╤В╤Л ╨┤╨╡╤В╨╡╨║╤В╨╛╤А╨╛╨╝). pydantic/TypedDict/╨░╨╗╨╕╨░╤Б╤Л ╨╕╨╝╨┐╨╛╤А╤В╨╛╨▓ (`NamedTuple as NT`) тАФ ╨▓╨╜╨╡ ╤Б╨║╨╛╤Г╨┐╨░ (╨┤╨╛╨║╤Г╨╝╨╡╨╜╤В╨╕╤А╨╛╨▓╨░╨╜╨╛ ╨▓ docstring). + - ╨а╨╛╨╗╤М (╨┐╨╛ Exp 9): ╤Б╤В╨░╤В╨╕╨║╨░ тАФ ╨╜╨╡ ╨╖╨░╨╝╨╡╨╜╨░ ╨┤╨╕╨╜╨░╨╝╨╕╨║╨╕; ╨┤╨╡╤В╨╡╨║╤В╨╛╤А = fallback ╨┤╨╗╤П ╨┤╨╕╨╜╨░╨╝╨╕╤З╨╡╤Б╨║╨╕-╨┐╤Г╤Б╤В╤Л╤Е ╤В╨╡╤Б╤В╨╛╨▓ + pre-filter ╤А╨░╨╜╨╢╨╕╤А╨╛╨▓╨░╨╜╨╕╤П (╨▓╤Е╨╛╨┤ ╨┤╨╗╤П `mscodebase bootstrap`, ╨и╨░╨│ 3). +- **╨Т╨╜╨╡╤И╨╜╤П╤П ╨▓╨░╨╗╨╕╨┤╨░╤Ж╨╕╤П ╨╜╨░ ╤З╤Г╨╢╨╕╤Е Python-╨┐╤А╨╛╨╡╨║╤В╨░╤Е (2026-09-17, anti-sleeveness):** + - ╨а╨╡╨▓╨╕╨╖╨╕╤П ╨▓╤Б╨╡╤Е 18 ╤А╨╡╨┐╨╛ `D:\Project\` (╤Б╤Г╨▒╨░╨│╨╡╨╜╤В): ╨╗╤Г╤З╤И╨╕╨╡ ┬л╤З╨╕╤Б╤В╤Л╨╡┬╗ ╨║╨░╨╜╨┤╨╕╨┤╨░╤В╤Л тАФ `gemma_agent` (1102 py / 464 test_*.py / git), `456789/ARCLUX` (TS), `bench_projects` (black/httpbin/headroom). ╨Ф╨╛ ╤Н╤В╨╛╨│╨╛ ╨┤╨╡╤В╨╡╨║╤В╨╛╤А ╨╕ TESTS-╤А╤С╨▒╤А╨░ ╨┐╤А╨╛╨▓╨╡╤А╤П╨╗╨╕╤Б╤М ╤В╨╛╨╗╤М╨║╨╛ ╨╜╨░ ╤Б╨╛╨▒╤Б╤В╨▓╨╡╨╜╨╜╨╛╨╝ ╤А╨╡╨┐╨╛. + - `bootstrap_entities.detect_entities` ╨╛╨▒╨╛╨▒╤Й╨░╨╡╤В╤Б╤П: **black(src)=16 dataclass / 2 NT / 73 classes**, **gemma_agent/core=65 dc / 228 classes**, gemma_agent/modules=1 dc, httpbin=0 (╤Б╤В╨░╤А╤Л╨╣ ╨║╨╛╨┤ ╨▒╨╡╨╖ dataclass). ╨Э╨░╨╣╨┤╨╡╨╜╨░ ╤Б╨╗╨╡╨┐╨╛╤В╨░: ╨┤╨╡╤В╨╡╨║╤В╨╛╤А ╨╢╤С╤Б╤В╨║╨╛ ╨╖╨░╨▓╤П╨╖╨░╨╜ ╨╜╨░ ╨┐╨╛╨┤╨║╨░╤В╨░╨╗╨╛╨│ `src/` тАФ gemma_agent ╨╕╤Б╨┐╨╛╨╗╤М╨╖╤Г╨╡╤В `core/`/`libraries/`/`modules/`, ╨╜╤Г╨╢╨╡╨╜ ╤П╨▓╨╜╤Л╨╣ `src_dir` (╨┐╨░╤А╨░╨╝╨╡╤В╤А ╤Г╨╢╨╡ ╨╡╤Б╤В╤М). + - **[╨а╨Х╨и╨Х╨Э╨Ю 2026-09-17]** ╨е╨░╤А╨┤╨║╨╛╨┤ `src/` ╤Г╤Б╤В╤А╨░╨╜╤С╨╜: `resolve_src_root()` тАФ ╨┤╨╡╤В╨╡╤А╨╝╨╕╨╜╨╕╤А╨╛╨▓╨░╨╜╨╜╤Л╨╣ ╨┐╤А╨╕╨╛╤А╨╕╤В╨╡╤В (╤П╨▓╨╜╤Л╨╣ `src_dir` тЖТ env `MSCODEBASE_BOOTSTRAP_SRC_DIR` тЖТ ╨╕╨╖╨▓╨╡╤Б╤В╨╜╤Л╨╡ ╤А╨░╤Б╨║╨╗╨░╨┤╨║╨╕ `src/lib/core/libraries/modules` тЖТ ╨║╨░╤В╨░╨╗╨╛╨│ ╤Б ╨╕╨╝╨╡╨╜╨╡╨╝ ╨┐╤А╨╛╨╡╨║╤В╨░ тЖТ ╤Б╤В╨░╤В╨╕╤Б╤В╨╕╤З╨╡╤Б╨║╨╕╨╣ fallback ╤Б ╨╕╤Б╨║╨╗╤О╤З╨╡╨╜╨╕╨╡╨╝ `tests/docs/venv`). ╨Т╨░╨╗╨╕╨┤╨░╤Ж╨╕╤П: MSCodeBaseтЖТsrc, gemma_agentтЖТcore (╤А╨░╨╜╨╡╨╡ ╤В╤А╨╡╨▒╨╛╨▓╨░╨╗ ╨╕╤Б╤В╨╛╤З╨╜╨╕╨║), blackтЖТsrc, httpbinтЖТhttpbin. ╨а╨╡╨╖╤Г╨╗╤М╤В╨░╤В╤Л ╤Б╨╛╨▓╨┐╨░╨┤╨░╤О╤В ╤Б ╤А╤Г╤З╨╜╤Л╨╝ `src_dir` (1:1). ╨в╨╡╤Б╤В╤Л 14/14, ╨▓ ╤В.╤З. 6 ╨╜╨╛╨▓╤Л╤Е (core-layout/╨╕╨╝╤П-╨┐╤А╨╛╨╡╨║╤В╨░/╤Б╤В╨░╤В╨╕╤Б╤В╨╕╨║╨░/env/╨┐╤А╨╕╨╛╤А╨╕╤В╨╡╤В-env/╨┐╤Г╤Б╤В╨╛╨╣-╨┐╤А╨╛╨│╨╛╨╜). Red Team 3/3 (venv-tests ╨╜╨╡ ╨╖╨░╤Е╨▓╨░╤В╤Л╨▓╨░╤О╤В╤Б╤П ╤Б╤В╨░╤В╨╕╤Б╤В╨╕╨║╨╛╨╣, ╨▒╨╕╤В╤Л╨╣ env ╨╜╨╡ ╨╗╨╛╨╝╨░╨╡╤В, ╨╛╤В╨╜╨╛╤Б╨╕╤В╨╡╨╗╤М╨╜╤Л╨╣ src_dir ╤А╨░╨▒╨╛╤В╨░╨╡╤В). + - **[╨и╨░╨│ 3 (Dynamic Trace command) тАФ ╨а╨Х╨Р╨Ы╨Ш╨Ч╨Ю╨Т╨Р╨Э, 2026-09-18]** + - `git mv experiments/bootstrap/dynamic_trace_plugin.py src/core/bootstrap_trace_plugin.py` тАФ ╨┐╨╗╨░╨│╨╕╨╜ ╤И╤В╨░╤В╨╜╤Л╨╣; ╨╕╨╝╨┐╨╛╤А╤В `-p src.core.bootstrap_trace_plugin`. +- `src/core/bootstrap_pipeline.py` (╨╛╤А╨║╨╡╤Б╤В╤А╨░╤В╨╛╤А: resolve_src_root тЖТ detect_entities тЖТ pytest subprocess ┬з5.16-safe тЖТ index_src_functions тЖТ build_tests_edges) + `bootstrap_tool.py` (`bootstrap_pipeline`, MCP+CLI). ╨Я╨╛╨┤╨▓╨╛╨┤╨╜╤Л╨╡ ╨║╨░╨╝╨╜╨╕ тАФ AGENT_DIARY 2026-09-18: PYTHONPATH ╤В╨╛╨╗╤М╨║╨╛ ╨┐╨╛ ╨░╨▓╤В╨╛-╨┤╨╡╤В╨╡╨║╤В╤Г `_plugin_importable` (namespace shadowing `src`-╨┐╨░╨║╨╡╤В╨░), BOM-guard `utf-8-sig`. + - ╨в╨╡╤Б╤В╤Л: `tests/test_bootstrap_pipeline.py` (5 ╨╕╨╜╤В╨╡╨│╤А╨░╤Ж., ╨▒╨╡╨╖ ╨╝╨╛╨║╨╛╨▓) + 3 ╨╜╨░ `index_src_functions`; 28/28 green + ╨┐╨╛╨╗╨╜╤Л╨╣ suite passed. ╨Ъ╨╗╨╕╨╡╨╜╤В ╨┐╨░╤А╨░╨╝╨╡╤В╤А╨╕╨╖╨╛╨▓╨░╨╜ ╨┐╨╛ env (`TRACE_SRC_ROOT`/`TRACE_OUT`) тЖТ ╤З╤Г╨╢╨╕╨╡ ╨┐╤А╨╛╨╡╨║╤В╤Л: gemma_agent 2737/2882 (95.0%) ╤В╨╡╤Б╤В╨╛╨▓ ╨╕╨╝╨╡╤О╤В тЙе1 src-╤Д╤Г╨╜╨║╤Ж╨╕╤О; black ╤Б╨║╨╛╨╝╨┐╨╕╨╗╨╕╤А╨╛╨▓╨░╨╜ ╨▓ `.pyd` тЖТ sys.settrace ╨╜╨╡ ╨╗╨╛╨▓╨╕╤В ╨╜╨░╤В╨╕╨▓╨╜╤Л╨╡ ╨║╨░╨┤╤А╤Л (fallback ╨╜╨░ ╤Б╤В╨░╤В╨╕╨║╤Г Exp 9 ╨╛╨▒╤П╨╖╨░╤В╨╡╨╗╨╡╨╜). +- **╨Т╨╡╨▒-╨╕╤Б╤Б╨╗╨╡╨┤╨╛╨▓╨░╨╜╨╕╨╡ ╨╕ audit ┬л╨│╨╕╨▒╨╗╤Л╤Е ╨╝╨╡╤Б╤В┬╗ (2026-09-15, ╨▓╤Б╤С ╨Я╨а╨Ю╨Т╨Х╨а╨Х╨Э╨Ю ╤Н╨╝╨┐╨╕╤А╨╕╤З╨╡╤Б╨║╨╕):** (1) **sysmon+dynamic_context тАФ ╨Ю╨Я╨а╨Ю╨Т╨Х╨а╨У╨Э╨г╨в╨Р**: ╨▓╨╡╤А╨╜╤Л╨╡ ╨║╨╛╨╜╤В╨╡╨║╤Б╤В╤Л ╨┤╨░╤С╤В pytest-╨║╨╛╨╗╨╗╨╡╨║╤Ж╨╕╤П, ╤А╤Г╤З╨╜╨╛╨╣ `switch_context` тЖТ ╨┐╤Г╤Б╤В╤Л╨╡ `['']` (coverage.py 7.14.1); (2) **╨║╨╛╨╜╤В╨╡╨║╤Б╤В╤Л тЙИ3-7% тАФ ╨Э╨Х ╨▓╨╛╤Б╨┐╤А╨╛╨╕╨╖╨▓╨╡╨╗╨╛╤Б╤М**: Exp 8 (2026-09-16) overhead **+19.96%** (221.78 vs 184.88s) > ╨╜╨░╤И╨╡╨│╨╛ sys.settrace (+13.6%) тЖТ ╤И╤В╨░╤В╨╜╤Л╨╣ ╨┤╤А╨░╨╣╨▓╨╡╤А ╨и╨░╨│╨░ 3 = `dynamic_trace_plugin.py`, coverage ╨╛╤Б╤В╨░╤С╤В╤Б╤П ╨▓╨░╨╗╨╕╨┤╨░╤Ж╨╕╨╛╨╜╨╜╤Л╨╝ ╨╛╤А╨░╨║╤Г╨╗╨╛╨╝ (╨║╨╛╨╜╤В╨╡╨║╤Б╤В╤Л ╨║╨░╤З╨╡╤Б╤В╨▓╨╡╨╜╨╜╤Л╨╡: 1548/1549, 75.5% src-╤Б╤В╤А╨╛╨║ ╨┐╤А╨╕╨▓╤П╨╖╨░╨╜╤Л); (3) **Tarantula тАФ Exp 7b**: rankтЙд3 ╤Г 22.6% ╤В╨╡╤Б╤В╨╛╨▓ (╨┤╨░╨╗╨╡╨║╨╛ ╨╛╤В 60-70%), ╨Э╨Ю precision ╨╜╨╕╨╖╨║╨╕╤Е ╤А╨░╨╜╨│╨╛╨▓ ╨▓╤Л╤Б╨╛╨║╨░ (╨▓╤Б╨╡ rank1-3 ╨▓╨╡╤А╨╜╤Л) тЖТ ╨░╨╜╨╜╨╛╤В╨░╤Ж╨╕╤П confidence (~16%), ╨╜╨╡ ╤Б╨╡╨╗╨╡╨║╤В╨╛╤А; TESTS-╤А╨╡╨▒╤А╨╛ ╤Б╤В╤А╨╛╨╕╤В╤Б╤П ╨╕╨╖ ╨┐╨╛╨╗╨╜╨╛╨╣ ╤В╤А╨░╤Б╤Б╤Л; (4) **mutation-testing ╨║╨░╨║ ground truth тАФ ╨┤╨╛╤А╨╛╨│╨╛/╤Е╤А╤Г╨┐╨║╨╛** (FSE'20, Google 33M; ╤Д╨╗╨░╨║╨╕ ╤А╨░╨╖╨┤╤Г╨▓╨░╤О╤В score); (5) **pytest-testmon тАФ ╨╜╨╡ ╨║╨╛╨┐╨╕╤А╤Г╨╡╨╝** (line-based, ╤Б╤Г╨╢╨╡╨╜╨╕╨╡ ╤А╨╡╤А╨░╨╜╨░ тЙа ╨│╤А╨░╤Д-╤А╨╡╨▒╤А╨╛ TESTS ╨┤╨╗╤П LLM-╨║╨╛╨╜╤В╨╡╨║╤Б╤В╨░); (6) **dev.to-╨║╤А╨╛╤Б╤Б-╤З╨╡╨║**: ┬лTRUE Coverage┬╗ (Dawson, 2026-07-22) ╨┐╨╛╨┤╤В╨▓╨╡╤А╨╢╨┤╨░╨╡╤В ╨┐╨╗╨░╤В╨╛ ╤Б╤В╨░╤В╨╕╨║╨╕ ╨╕ ╤И╤Г╨╝ shared-utils (╨╜╨░╤И safe_mkdir/get_data_root ╨║╨╡╨╣╤Б 1:1; CI 43minтЖТ4min, precision 15%тЖТ95%); ┬лEmpirical Failure Modes┬╗ (Arthur, 2026-07-31) тАФ Pass-Through Test Mirage (╨╜╨░╤И ┬л╤Д╨░╨╜╤В╨╛╨╝╨╜╤Л╨╣ ╨║╨╛╨┤┬╗), Python 3.14 sys.monitoring reachability = ╨╜╨░╤И ╨▒╤Н╨║╨╡╨╜╨┤, AST orphan-detection = ╨╜╨░╤И ╨и╨░╨│ 1; **╨╜╨╕╤И╨░ TESTS-╤А╤С╨▒╨╡╤А ╨┤╨╗╤П LLM-╨║╨╛╨╜╤В╨╡╨║╤Б╤В╨░ ╨╕╨╝╨╕ ╨╜╨╡ ╨╖╨░╨╜╤П╤В╨░** (per-test coverage ╨╕╤Б╨┐╨╛╨╗╤М╨╖╤Г╨╡╤В╤Б╤П ╤В╨╛╨╗╤М╨║╨╛ ╨┤╨╗╤П selection/rejection); (7) edge-case (Gemini): ╨▒╨╡╨╖ ╤В╨╡╤Б╤В╨╛╨▓ тЖТ ╤Б╤В╨░╤В╨╕╨║╨░; ╨▒╨╕╨╜╨░╤А╨╜╨╕╨║╨╕ тЖТ Docker+microtrace; async тЖТ OpenTelemetry ╨┐╨╛ trace_id. + +## 2026-09-11 тАФ Burst-rename: fail-closed VOR ╨╛╤В╨╖╤Л╨▓╨░╨╡╤В 100% ╨┐╤А╨╕ ONE rename-sweep (╨╛╤В╨▓╨╡╤В Statewave ╨╜╨░ dev.to) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** Closed (╤Н╨║╤Б╨┐╨╡╤А╨╕╨╝╨╡╨╜╤В╤Л, ╨╛╤В╨▓╨╡╤В ╨╛╨┐╤Г╨▒╨╗╨╕╨║╨╛╨▓╨░╨╜) +**Root Cause:** VOR (ADR-0003) ╨┐╤А╨╛╨▓╨╡╤А╤П╨╡╤В ╨Я╨г╨в╨м-╤П╨║╨╛╤А╤П ╨┐╤А╨╛╤В╨╕╨▓ ╤В╨╡╨║╤Г╤Й╨╡╨│╨╛ HEAD. Rename/move = ╤Б╤В╨░╤А╤Л╨╣ ╨┐╤Г╤В╤М ╨╛╤В╤Б╤Г╤В╤Б╤В╨▓╤Г╨╡╤В = SILENT_ABSENCE = ╨╛╤В╨╖╤Л╨▓, ╤Е╨╛╤В╤П ╤Д╨░╨╣╨╗... +- **╨б╤В╨░╤В╤Г╤Б:** ╨░╨▓╤В╨╛╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╕ ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╕╨╖╨╕╤А╨╛╨▓╨░╨╜╨╛ + +## 2026-09-11 тАФ VOR read-path fix (PR #34) + ┬л8-╨╝╨╕╨╜╤Г╤В╨╜╤Л╨╣ ╨║╨╛╨╝╨╝╨╕╤В┬╗ = ╨Э╨Х ╨▒╨░╨│ (╤А╨╡╤И╨╡╨╜╨╕╨╡ ╨▓╨╗╨░╨┤╨╡╨╗╤М╤Ж╨░) + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** тЬЕ PR #34 ╤Б╨╛╨╖╨┤╨░╨╜, hooks green; ╤Б╨║╨╛╤А╨╛╤Б╤В╤М ╤В╨╡╤Б╤В╨╛╨▓ тАФ ╨╛╤Б╨╛╨╖╨╜╨░╨╜╨╜╨╛╨╡ ╤А╨╡╤И╨╡╨╜╨╕╨╡, ╨║╨╛╨┤ ╨Э╨Х ╨╝╨╡╨╜╤П╨╗╤Б╤П. +**Root Cause:** (1) read-path VOR ╤А╨╡-╤Б╨║╨░╨╜╨╕╤А╨╛╨▓╨░╨╗ prose ╤В╨╡╨╗╨░ ADR ╤З╨╡╤А╨╡╨╖ `_PATH_RE`, ╤Е╨╛╤В╤П ╤П╨▓╨╜╤Л╨╡ `data.anchor... +- **╨б╤В╨░╤В╤Г╤Б:** ╨░╨▓╤В╨╛╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╕ ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╕╨╖╨╕╤А╨╛╨▓╨░╨╜╨╛ + +## 2026-09-10 тАФ Exp 1 (Catch-up Rate) + Exp 3 (HEAD polling): VOR ╨╝╨░╤Б╤И╤В╨░╨▒╨╕╤А╨╛╨▓╨░╨╜╨╕╨╡ ╨╕ ╨▓╨╜╨╡╤И╨╜╨╕╨╣ ╨┤╤А╨╕╤Д╤В + +- **╨Ш╤Б╤В╨╛╤З╨╜╨╕╨║:** AGENT_DIARY.md +- **╨Ю╨┐╨╕╤Б╨░╨╜╨╕╨╡:** **Status:** тЬЕ Fix (╨╖╨░╨╝╨╡╤А╤Л, ╨║╨╛╨┤╨░ ╨╜╨╡ ╨╝╨╡╨╜╤П╨╗╨╛╤Б╤М). **Root Cause (KNOW ISSUES ┬лLazy-only ╨▓╨╡╤А╨╕╤Д╨╕╨║╨░╤Ж╨╕╤П┬╗):** ╨▓╨╛╨┐╤А╨╛╤Б, ╤Г╤Б╨┐╨╡╨▓╨░╨╡╤В ╨╗╨╕ VOR ╨┐╤А╨╛╨▓╨╡╤А╨╕╤В╤М ACTIVE-╤Г╨╖╨╗╤Л ╨▓ ╤А╨░╨╝╨║╨░╤Е budget_ms=50 (read-path) / 250 (background id... +- **╨б╤В╨░╤В╤Г╤Б:** ╨░╨▓╤В╨╛╨╝╨░╤В╨╕╤З╨╡╤Б╨║╨╕ ╤Б╨╕╨╜╤Е╤А╨╛╨╜╨╕╨╖╨╕╤А╨╛╨▓╨░╨╜╨╛ diff --git a/docs/archive/KNOWN_ISSUES_2026_09.md b/docs/archive/KNOWN_ISSUES_2026_09.md index 15eec71e..f1ecc726 100644 --- a/docs/archive/KNOWN_ISSUES_2026_09.md +++ b/docs/archive/KNOWN_ISSUES_2026_09.md @@ -168,3 +168,365 @@ **Решение (приоритет):** KI-R1 (пер... - **Статус:** автоматически синхронизировано + +## 2026-09-10 — Exp 2 (Agent Behavior) + Exp 4 (Fail-Closed Freshness Gate) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed. **Root Cause (Exhibit #23, 2026-09-09):** inform-the-agent approach insufficient — agent can ignore STALE alerts; PlanFence 30/30 failures confirms action-validation unreliable; s... +- **Статус:** автоматически синхронизировано + +## 2026-09-13 — H4: agent-memory lifecycle в масштабе dev.to KB — бутылочное горлышко = сетевой capture, не граф + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (эксперимент подтверждён; сопровождение задачи closed) +**Root Cause:** при росте базы 3,989 → 13,519 статей (3.4x), refresh own занял 10м38с на 13.5k статей/82.5k комментов (134 сете... +- **Статус:** автоматически синхронизировано + +## 2026-09-22 — Exp E16: переносимость bootstrap trace на чужие проекты (статья CoderLegion) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Measured (hypothesis CONFIRMED) +**Hypothesis:** динамический трейс (sys.settrace, `src/core/bootstrap_trace_plugin.py`) воспроизводится на чужих Python-репозиториях без правок плагина; lin... +- **Статус:** автоматически синхронизировано + + +## 2026-09-22 — Exp E14: Embedder A/B — EmbeddingGemma 300M vs e5-small (production) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Measured (hypothesis CONFIRMED) +**Hypothesis:** gemma 300M (768-dim, ctx 2048) значительно сильнее e5-small (384-dim, ctx 512) на кодовом ретривале при цене 3-4× медленнее на CPU. +**Method... +- **Статус:** автоматически синхронизировано + + +## 2026-09-20 — Exp E13: текстовый RAG (doc-chunks) vs кодовый baseline (E10/E11) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Measured (refuted hypothesis) +**Hypothesis:** doc-chunks (README + docs/en/ + docstrings) retrieve as well as code-chunks via search_with_mode quality. +**Method:** 16 EN doc-queries, live ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-18 — Фаза 1: Incremental Hot-Reload (FreshnessChecker оживлён + hot-reload + KI-109) + +- **Источник:** AGENT_DIARY.md +- **Описание:** - **Evidence Ladder (2026-08-15, Exp 2-E E1-E3):** форма evidence — переменная; file_content = лучший recall (qwen 0.92), graph = закрытие present-trap ТОЛЬКО у evidence-честных моделей (qwen3.7 FA tr... +- **Статус:** автоматически синхронизировано + + +## 2026-09-18 — Фаза 1: Incremental Hot-Reload (FreshnessChecker оживлён + hot-reload + KI-109) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (7 тестов свежести включая concurrency-стресс N=16 + 1748 полный pytest green; ветка вне PR — локально) +**Root Cause:** FreshnessChecker (freshness.py) был мёртв (0 вызовов) и СЛОМАН... +- **Статус:** автоматически синхронизировано + + +## 2026-09-07 — Lazy-only верификация: VOR вызывается только из intel_get_project_memory, нет TTL/фона + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Open — зафиксировано как проблема + план эксперимента (10-continuous-verification.md) +**Root Cause:** По дизайну (ADR-0003) VOR ленивый, но точки вызова всего одна (layer.py:1097); IdleSch... +- **Статус:** автоматически синхронизировано + + +## 2026-09-09 — H1: фоновый VOR-проход (IdleScheduler) — память перепроверяется без вызова агента + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (6 новых тестов + 1674 полный pytest green; ветка chore/experiments-es1-es2-0909) +**Root Cause:** VOR вызывался ровно из 1 места (intel_get_project_memory, layer.py:1097); idle-задач... +- **Статус:** автоматически синхронизировано + + +## 2026-09-09 — H2: .h заголовки C включены в AST-индексацию (PARSE_EXTENSIONS + C-парсер) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (commit 0301fa93; KNOWN_ISSUES 2026-09-09 19:35 закрыт) +**Root Cause:** ".h" был в INDEX_EXTENSIONS (вектор-чанкинг шёл), но НЕ в PARSE_EXTENSIONS → CodeParser.parse_file возвращал [... +- **Статус:** автоматически синхронизировано + + +## 2026-09-07 — Cypher-движок: анонимные узлы/рёбра ломали MATCH; ActionReceipt не писался из write-пути + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (оба блока закрыты, тесты зелёные) +**Root Cause:** (1) Cypher: `from_node_alias` дефолтил в `n1`, а генератор создавал `n{path_idx*2}` для анонимного узла → `no such column: n0.id`; ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-03 — Fake reindex ETA "~8s" + frozen progress in Finalizing (both fixed) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (commit 32f11662; 5 pre-commit hooks OK; full pytest 1587 passed, 2 pre-existing unrelated env_extractor failures) +**Root Cause 1 (ETA "~8s"):** `_enrich_job_response` had a dead h... +- **Статус:** автоматически синхронизировано + + +## 2026-09-03 19:30 — CI RED: circular import layer ↔ tools_reg (architecture_linter) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (commit f210ed7c; CI all-jobs green on ubuntu+windows) +**Root Cause:** My ETA refactor added `tools_reg → layer` import for `_embed_progress_from_log`, closing an existing `layer →... +- **Статус:** автоматически синхронизировано + + +## 2026-09-04 11:15 — CI RED: ruff lint errors caught only after push (3 commits) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (commit 986c9be7) +**Root Cause:** Pre-commit hook did not run ruff. CI (`ruff check src/ tests/` in ci.yml) caught F401/W292 only after push, forcing fix-commits. Repeated 3 times ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-05 12:30 — FIX: stale_detector + predict_change стабильно таймаутили через MCP (-32001): блокирующий sync-код в async-контексте + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (code only, не запушено) — src/mcp/tools/doc_tools.py + predict_tools.py +**Root Cause:** `error_boundary` применяет `asyncio.wait_for(timeout_ms)` вокруг `execute`, но внутри `exec... +- **Статус:** автоматически синхронизировано + + +## 2026-09-06 21:00 — Починка lock_guard: таймаут 60s ломал весь .locks-протокол + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed / **Root Cause:** `scripts/lock_guard.py` `_run` default timeout=60s — любой `git commit` прогоняет pre-commit hook (verify_diary → полный pytest 5-10 мин на Windows), поэтому acqu... +- **Статус:** автоматически синхронизировано + + +## 2026-09-06 21:30 — sync-subprocess в async-MCP (context_tool, system_tools) — fixed + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (code only) / **Root Cause:** системная проверка после фикса stale/predict: нашлись ещё sync `subprocess.run` внутри async `execute`. `GetContextTool._section_git` (git log через s... +- **Статус:** автоматически синхронизировано + + +## 2026-09-06 22:00 — P-001 рецидив: cmd-окна при запуске/открытии проекта (powershell/nvidia-smi без CREATE_NO_WINDOW) — FIXED + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed / **Root Cause:** повтор инцидента 2026-08-14 (P-001, «чёрные окна CMD»). Фикс 2026-08-14 добавил CREATE_NO_WINDOW для git/netstat/wmic/taskkill в runtime, но ПОЗВОЛИЛ дыру: `resou... +- **Статус:** автоматически синхронизировано + + +## 2026-09-08 19:40 — collect() в Cypher: json_group_array + типизированный декод (fixed) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed. / **Root Cause:** KNOWN_ISSUES 2026-09-07 ⏳ — `_translate_return_expr` заявлял `collect` как Supported, но SQLite не имеет функции COLLECT («no such function»); ни одного теста на... +- **Статус:** автоматически синхронизировано + + +## 2026-09-09 — Аудит «Active MSCodeBase» (Exhibit #23: MCP tool available but never invoked) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Open — зафиксирован гэп (исследование + план, код НЕ вносился) +**Root Cause:** фундамент (VOR / DebounceBatch / ConsistencyTracker / IdleScheduler / PropagationEngine) существует, но компо... +- **Статус:** автоматически синхронизировано + + +## 2026-09-10 — H1 idle-VOR + system_alerts (цепь «файл изменён → STALE → VOR → alert агента» собрана) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed / **Root Cause (Exhibit #23, 2026-09-09):** компоненты цепи существовали по отдельности, но VOR вызывался ровно из 1 места (layer.py:intel_get_project_memory), mark_stale("memory")... +- **Статус:** автоматически синхронизировано + + +## 2026-09-11 — H3 TTL-гниение: last_checked для всех проверенных + label stale_ttl (doc 10 closed) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (9 новых тестов + 1725 полный pytest green; doc 10-continuous-verification H1+H2+H3 done) +**Root Cause:** INCONCLUSIVE/непроверенные узлы «висят вечно» без следа проверки: live-срез ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-20 — Поисковое качество / E13: исследовательские задачи (6 пунктов) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Plan (задачи занесены в ISSUE.md KI-R1..R6, код не тронут) +**Контекст:** исследование поиска/RAG — что именно измерять, прежде чем утверждать результат. +**Решение (приоритет):** KI-R1 (пер... +- **Статус:** автоматически синхронизировано + + + +--- + +## Archived 2026-09-26 (auto-synced tail, moved to satisfy <=300-line check) +## 2026-09-22 — Exp E16: переносимость bootstrap trace РЅР° чужие проекты (статья CoderLegion) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Measured (hypothesis CONFIRMED) +**Hypothesis:** динамический трейс (sys.settrace, `src/core/bootstrap_trace_plugin.py`) воспроизводится РЅР° чужих Python-репозиториях без правок плагина; lin... +- **Статус:** автоматически синхронизировано + + +## 2026-09-22 — Exp E14: Embedder A/B — EmbeddingGemma 300M vs e5-small (production) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Measured (hypothesis CONFIRMED) +**Hypothesis:** gemma 300M (768-dim, ctx 2048) значительно сильнее e5-small (384-dim, ctx 512) РЅР° РєРѕРґРѕРІРѕРј ретривале РїСЂРё цене 3-4Г— медленнее РЅР° CPU. +**Method... +- **Статус:** автоматически синхронизировано + + +## 2026-09-20 — Exp E13: текстовый RAG (doc-chunks) vs кодовый baseline (E10/E11) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Measured (refuted hypothesis) +**Hypothesis:** doc-chunks (README + docs/en/ + docstrings) retrieve as well as code-chunks via search_with_mode quality. +**Method:** 16 EN doc-queries, live ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-18 — Фаза 1: Incremental Hot-Reload (FreshnessChecker оживлён + hot-reload + KI-109) + +- **Источник:** AGENT_DIARY.md +- **Описание:** - **Evidence Ladder (2026-08-15, Exp 2-E E1-E3):** форма evidence — переменная; file_content = лучший recall (qwen 0.92), graph = закрытие present-trap ТОЛЬКО Сѓ evidence-честных моделей (qwen3.7 FA tr... +- **Статус:** автоматически синхронизировано + + +## 2026-09-18 — Фаза 1: Incremental Hot-Reload (FreshnessChecker оживлён + hot-reload + KI-109) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (7 тестов свежести включая concurrency-стресс N=16 + 1748 полный pytest green; ветка РІРЅРµ PR — локально) +**Root Cause:** FreshnessChecker (freshness.py) был мёртв (0 вызовов) Рё СЛОМАН... +- **Статус:** автоматически синхронизировано + + +## 2026-09-07 — Lazy-only верификация: VOR вызывается только РёР· intel_get_project_memory, нет TTL/фона + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Open — зафиксировано как проблема + план эксперимента (10-continuous-verification.md) +**Root Cause:** РџРѕ дизайну (ADR-0003) VOR ленивый, РЅРѕ точки вызова всего РѕРґРЅР° (layer.py:1097); IdleSch... +- **Статус:** автоматически синхронизировано + + +## 2026-09-09 — H1: фоновый VOR-РїСЂРѕС…РѕРґ (IdleScheduler) — память перепроверяется без вызова агента + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (6 новых тестов + 1674 полный pytest green; ветка chore/experiments-es1-es2-0909) +**Root Cause:** VOR вызывался СЂРѕРІРЅРѕ РёР· 1 места (intel_get_project_memory, layer.py:1097); idle-задач... +- **Статус:** автоматически синхронизировано + + +## 2026-09-09 — H2: .h заголовки C включены РІ AST-индексацию (PARSE_EXTENSIONS + C-парсер) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (commit 0301fa93; KNOWN_ISSUES 2026-09-09 19:35 закрыт) +**Root Cause:** ".h" был РІ INDEX_EXTENSIONS (вектор-чанкинг шёл), РЅРѕ РќР• РІ PARSE_EXTENSIONS в†’ CodeParser.parse_file возвращал [... +- **Статус:** автоматически синхронизировано + + +## 2026-09-07 — Cypher-РґРІРёР¶РѕРє: анонимные узлы/рёбра ломали MATCH; ActionReceipt РЅРµ писался РёР· write-пути + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (РѕР±Р° блока закрыты, тесты зелёные) +**Root Cause:** (1) Cypher: `from_node_alias` дефолтил РІ `n1`, Р° генератор создавал `n{path_idx*2}` для анонимного узла в†’ `no such column: n0.id`; ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-03 — Fake reindex ETA "~8s" + frozen progress in Finalizing (both fixed) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed (commit 32f11662; 5 pre-commit hooks OK; full pytest 1587 passed, 2 pre-existing unrelated env_extractor failures) +**Root Cause 1 (ETA "~8s"):** `_enrich_job_response` had a dead h... +- **Статус:** автоматически синхронизировано + + +## 2026-09-03 19:30 — CI RED: circular import layer ↔ tools_reg (architecture_linter) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed (commit f210ed7c; CI all-jobs green on ubuntu+windows) +**Root Cause:** My ETA refactor added `tools_reg в†’ layer` import for `_embed_progress_from_log`, closing an existing `layer в†’... +- **Статус:** автоматически синхронизировано + + +## 2026-09-04 11:15 — CI RED: ruff lint errors caught only after push (3 commits) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed (commit 986c9be7) +**Root Cause:** Pre-commit hook did not run ruff. CI (`ruff check src/ tests/` in ci.yml) caught F401/W292 only after push, forcing fix-commits. Repeated 3 times ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-05 12:30 — FIX: stale_detector + predict_change стабильно таймаутили через MCP (-32001): блокирующий sync-РєРѕРґ РІ async-контексте + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed (code only, РЅРµ запушено) — src/mcp/tools/doc_tools.py + predict_tools.py +**Root Cause:** `error_boundary` применяет `asyncio.wait_for(timeout_ms)` РІРѕРєСЂСѓРі `execute`, РЅРѕ внутри `exec... +- **Статус:** автоматически синхронизировано + + +## 2026-09-06 21:00 — Починка lock_guard: таймаут 60s ломал весь .locks-протокол + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed / **Root Cause:** `scripts/lock_guard.py` `_run` default timeout=60s — любой `git commit` прогоняет pre-commit hook (verify_diary в†’ полный pytest 5-10 РјРёРЅ РЅР° Windows), поэтому acqu... +- **Статус:** автоматически синхронизировано + + +## 2026-09-06 21:30 — sync-subprocess РІ async-MCP (context_tool, system_tools) — fixed + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed (code only) / **Root Cause:** системная проверка после фикса stale/predict: нашлись ещё sync `subprocess.run` внутри async `execute`. `GetContextTool._section_git` (git log через s... +- **Статус:** автоматически синхронизировано + + +## 2026-09-06 22:00 — P-001 рецидив: cmd-РѕРєРЅР° РїСЂРё запуске/открытии проекта (powershell/nvidia-smi без CREATE_NO_WINDOW) — FIXED + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed / **Root Cause:** повтор инцидента 2026-08-14 (P-001, «чёрные РѕРєРЅР° CMDВ»). Фикс 2026-08-14 добавил CREATE_NO_WINDOW для git/netstat/wmic/taskkill РІ runtime, РЅРѕ ПОЗВОЛИЛ дыру: `resou... +- **Статус:** автоматически синхронизировано + + +## 2026-09-08 19:40 — collect() РІ Cypher: json_group_array + типизированный декод (fixed) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed. / **Root Cause:** KNOWN_ISSUES 2026-09-07 вЏі — `_translate_return_expr` заявлял `collect` как Supported, РЅРѕ SQLite РЅРµ имеет функции COLLECT (В«no such functionВ»); РЅРё РѕРґРЅРѕРіРѕ теста РЅР°... +- **Статус:** автоматически синхронизировано + + +## 2026-09-09 — РђСѓРґРёС‚ В«Active MSCodeBaseВ» (Exhibit #23: MCP tool available but never invoked) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Open — зафиксирован РіСЌРї (исследование + план, РєРѕРґ РќР• вносился) +**Root Cause:** фундамент (VOR / DebounceBatch / ConsistencyTracker / IdleScheduler / PropagationEngine) существует, РЅРѕ РєРѕРјРїРѕ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-10 — H1 idle-VOR + system_alerts (цепь «файл изменён в†’ STALE в†’ VOR в†’ alert агента» собрана) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed / **Root Cause (Exhibit #23, 2026-09-09):** компоненты цепи существовали РїРѕ отдельности, РЅРѕ VOR вызывался СЂРѕРІРЅРѕ РёР· 1 места (layer.py:intel_get_project_memory), mark_stale("memory")... +- **Статус:** автоматически синхронизировано + + +## 2026-09-10 — Exp 2 (Agent Behavior) + Exp 4 (Fail-Closed Freshness Gate) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** вњ… Fixed. **Root Cause (Exhibit #23, 2026-09-09):** inform-the-agent approach insufficient — agent can ignore STALE alerts; PlanFence 30/30 failures confirms action-validation unreliable; s... +- **Статус:** автоматически синхронизировано + + +## 2026-09-11 — H3 TTL-гниение: last_checked для всех проверенных + label stale_ttl (doc 10 closed) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (9 новых тестов + 1725 полный pytest green; doc 10-continuous-verification H1+H2+H3 done) +**Root Cause:** INCONCLUSIVE/непроверенные узлы «висят вечно» без следа проверки: live-срез ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-13 — H4: agent-memory lifecycle РІ масштабе dev.to KB — бутылочное горлышко = сетевой capture, РЅРµ граф + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (эксперимент подтверждён; сопровождение задачи closed) +**Root Cause:** РїСЂРё росте базы 3,989 в†’ 13,519 статей (3.4x), refresh own занял 10Рј38СЃ РЅР° 13.5k статей/82.5k комментов (134 сете... +- **Статус:** автоматически синхронизировано + + +## 2026-09-20 — РџРѕРёСЃРєРѕРІРѕРµ качество / E13: исследовательские задачи (6 пунктов) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Plan (задачи занесены РІ ISSUE.md KI-R1..R6, РєРѕРґ РЅРµ тронут) +**Контекст:** исследование РїРѕРёСЃРєР°/RAG — что именно измерять, прежде чем утверждать результат. +**Решение (приоритет):** KI-R1 (пер... +- **Статус:** автоматически синхронизировано + diff --git a/docs/research/ideal_system_patterns_2026-09-25.md b/docs/research/ideal_system_patterns_2026-09-25.md new file mode 100644 index 00000000..59226e02 --- /dev/null +++ b/docs/research/ideal_system_patterns_2026-09-25.md @@ -0,0 +1,72 @@ +# Ideal-system patterns — research synthesis (2026-09-25) + +Sources: dev.to posts + comment threads (live API), plus web (Temporal, ES/Solr, +Postgres, LangChain). Each pattern is a rule we can implement, anchored to its source. + +## Patterns + +### P-A. "Green while broken" — status != health +Source: "Uptime Is Not an Agent SLO" — raju_dandigam (4619821) + comments. +Rule: an endpoint can be 200 / an indicator 95% while the outcome is wrong. Use a +**scorecard of separate indicators** (availability / outcome / safety / latency / +efficiency / escalation), never one blended number; "do not average safety into +availability". Track **unknown-outcome rate** separately. +Our case: job showed "Finalizing 95%" while hung = availability green, product dead. + +### P-B. "still digging" == "hung" without a heartbeat +Source: Temporal long-running-activity heartbeat pattern; Raven issue #153. +Rule: long tasks must emit **per-phase heartbeat + progress details + wait_reason** +(what are we waiting on — lock/IO/native), with **stall detection** (no heartbeat +N sec in an active phase => STALLED, not "in progress"). + +### P-C. Failure result is multi-axis, not PASS/FAIL +Source: "I Injected the Same Failures Into 5 MCP SDKs" — anilloutombam (4694934) + +comments (raju_dandigam, mihai_leanzero). +Rule: report (1) conformance, (2) **session survivability** (send a normal request +AFTER the fault), (3) **diagnostic visibility**. "Triggering a fault only tells you +what happened during the fault; it doesn't tell you whether the state survived it." + +### P-D. Diagnostic visibility is a first-class axis +Source: same + MCP error envelopes (Orisu/Perplexity/xAI, GitHub style guide). +Rule: every failure carries `{code, message, next_action, details}` so an agent can +branch and recover. Format: "what's wrong -> what's expected -> example". + +### P-E. Deterministic failure injection is the guard's guard +Source: MCP Failure Lab (4694934); our own guard-inventory doctrine. +Rule: a guard that cannot fail is worthless. Inject the failure deterministically +and assert recovery + visibility. + +### P-F. Metric bug families (numbers go wrong) +Source: "We audited 110 AI usage tools" — roytong (4719629) + comments. +- F1 **stale constants** quoted confidently (=> our `graph_score=0.4`, frozen ETA). +- F3 **retry double-counting** (byte-identical re-emits counted twice => our dup/bloat). +- F4 **absent treated as zero** (`or 0` turns "unknown" into "free"; rollups must be + able to say **UNPROVABLE**, not 0) => our exp-16 "0 eligible vs 0 collected". +- F5 **window-boundary / absolute-date fixtures** (passes 30d, fails everywhere) => + our absolute-date test flakes. +Rule: numbers must be traceable to named rules; allow UNPROVABLE; verify your own bill. + +### P-G. Incremental correctness +Source: shirokoff "RAG freshness"; LangChain Indexing API; Solr signature dedup; +canopyide FTS5 issue; Hermes storage diet. +Rule: update = **delete-then-insert by content hash** (not upsert); stable doc id +(path is not an id); doc->chunk lineage for delete-by-filter; FTS5 +**delete-before-add** (stale tokens otherwise); **delete test on day one**; +query-time freshness check. Supplementary: 3-layer dedup (exact hash -> Jaccard -> +cosine). + +## Gap -> pattern map +- 3 disagreeing indicators + stale ETA => P-A, P-B, P-F1. +- hung job invisible => P-B, P-C. +- `{"ok":false,"error":"..."}` no hint => P-D. +- index ~2x bloat, FTS5 add-only => P-G, P-F3. +- guard blind spots (optimize untested) => P-C, P-E. + +## Roadmap (each slice: Phase Zero -> Experiment -> Fix -> Guard -> Record) +- S1 [done] error_envelope core (P-D). +- S2 wire envelope into MCP error paths (P-D). +- S3 per-phase heartbeat + stall detection + wait_reason (P-A/P-B). +- S4 learned per-phase ETA, UNPROVABLE allowed (P-B/P-F1/P-F4). +- S5 incremental correctness: hash-gate delete-then-insert, FTS5 delete-before-add, + dedup, delete test, integrity report (P-G/P-F3). +- S6 failure-injection lab for the indexer/watchdog; multi-axis results (P-C/P-E). diff --git a/docs/research/indexer_chain_map_2026-09-25.md b/docs/research/indexer_chain_map_2026-09-25.md new file mode 100644 index 00000000..e9f94a87 --- /dev/null +++ b/docs/research/indexer_chain_map_2026-09-25.md @@ -0,0 +1,53 @@ +# Indexer chain map — end to end (2026-09-25) + +Full pipeline inventory: every link, whether it can hang, what is observable, what +guard exists. Purpose: build the nervous system (S3) from facts, and bound every +non-cancellable native call (same class as the fixed `_safe_optimize`). + +Source: `index_project_runner.run()` (255-630), `layer._run_reindex_job` (730-800), +`db_writer`, `engine.hybrid_search_async`. + +## Chain (trigger -> done) + +| # | Link | Can hang? | Observable now | Guard now | +|---|------|-----------|----------------|-----------| +| 0 | path safety check | no | — | is_safe_to_process | +| 1 | `begin_write()` lock | yes (waits) | — | soft-wait on live holder | +| 2 | `os.walk` scan | slow only | progress "scanning" 0/total | none | +| 3 | `_verify_and_repair_table_integrity` | **yes (native)** | no heartbeat | none | +| 4 | known_hashes `to_lance().to_pandas` | **yes (native)** | no | none | +| 5 | Phase 1 parse (ThreadPoolExecutor) | was yes | heartbeat `parse:{name}` + progress "parsing" | **bounded** (run_bounded 60s/file) | +| 6 | embed `embedder.embed_batch` | **yes (llama.cpp)** | heartbeat `embed:done/total` + progress "embedding" | **none** (no per-batch bound) | +| 7 | per-file `bulk_write` (LanceDB) | **yes (native)** | heartbeat `write:{name}` | **none** | +| 8 | `_safe_prune` | **yes** | no | none | +| 9 | BM25 reindex (`searcher`) | **yes** | no | none | +| 10 | `_safe_ivf_index` (optimize/create_index) | was yes | progress "finalizing" 0.95 | **bounded** (run_bounded) | +| 11 | `summarizer.save_cache` | yes | no | none | +| 12 | `save_symbol_index` | **yes (native)** | no | none | +| 13 | post-run: symbol index / auto-doc (`layer`) | yes | job.progress 0.8->1.0 | auto-doc: to_thread + wait_for(300) | +| S | search: `hybrid_search_async` (dense/FTS5/BM25/rerank/graph) | yes | intel_tool_health/latency | embedded bounds (some run_bounded now) | + +## Findings from the chain (not yet fixed) +- **Unbounded native calls remain (#3,#4,#6,#7,#8,#9,#11,#12)** — the same + non-cancellable class as `_safe_optimize`; any native stall freezes the phase. +- **Heartbeat coverage is partial**: only parse/embed/write emit `watchdog_heartbeat` + and progress. prune/BM25/IVF/symbol/docs are dark. +- **No `wait_reason`**: when CPU is 0 and the phase "runs", nothing says what it waits + on (lock / IO / native). Postgres `wait_event` has this; we don't. +- **No stall detection**: nothing turns "no heartbeat for N sec in an active phase" + into STALLED. The job stays "95% Finalizing" forever (P-A/P-B). + +## Experimental protocol (per slice, per §5/§7) +Each slice ships: (a) Phase-Zero note, (b) deterministic **failure injection** of the +link under test, (c) fixed version, (d) **negative control that must fail on old code +and pass on new**, (e) full-chain smoke (index a real repo end-to-end), (f) record. +Acceptance is multi-axis (P-C): conformance + session/state survivability + diagnostic +visibility — never a single PASS. + +## Roadmap link coverage +- S3 (nervous system): add `wait_reason` + heartbeat to ALL links + stall detector. +- S4 (ETA): per-phase learned durations; UNPROVABLE when no driver (P-F). +- S5 (incremental): #7/#8/#9 correctness (delete-then-insert hash-gate; FTS5 + delete-before-add; dedup) + integrity report. +- S6 (failure lab): inject hang at #3/#4/#6/#7 and assert bounded + visible. +- Fix pass: bound #3,#4,#6,#7,#8,#9,#11,#12 with `run_bounded`. diff --git a/experiments/misc_probes/exp_graph_mutex_cross_thread.py b/experiments/misc_probes/exp_graph_mutex_cross_thread.py new file mode 100644 index 00000000..e01439cb --- /dev/null +++ b/experiments/misc_probes/exp_graph_mutex_cross_thread.py @@ -0,0 +1,75 @@ +"""Experiment: graph.db cross-process mutex is not reentrant across threads. + +Reproduces the reindex finalize failure + "Could not acquire cross-process lock for graph.db within 30000ms" +by holding the named mutex on one thread and trying to acquire it on another. + +HYPOTHESIS: with the same process, a second ACQUIRE (different thread) blocks +and returns False once timeout_ms elapses. => any concurrent graph operation +(live search / symbol build) held longer than the timeout fails the other one. + +Run: PYTHONPATH=src python experiments/misc_probes/exp_graph_mutex_cross_thread.py +""" +from __future__ import annotations + +import sys +import tempfile +import threading +import time +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from src.core.graph import _CrossProcessMutex # noqa: E402 + +DB = Path(tempfile.mkdtemp(prefix="graphmutex_")) / "graph.db" +HOLD = 3.0 +TMO_MS = 800 + + +def main() -> int: + if sys.platform != "win32": + print("non-win32: mutex is a no-op; skipped") + return 0 + + a = _CrossProcessMutex(DB) + assert a.acquire(1000), "first acquire should succeed" + + result = {} + + def second(): + b = _CrossProcessMutex(DB) + t0 = time.perf_counter() + ok = b.acquire(TMO_MS) # different thread, same named mutex + result["ok"] = ok + result["dt"] = time.perf_counter() - t0 + if ok: + b.release() + + th = threading.Thread(target=second) + th.start() + time.sleep(HOLD) # hold the mutex past B's timeout + a.release() + th.join(timeout=5) + + print(f"hold={HOLD}s second acquire timeout={TMO_MS}ms") + print(f"second thread acquired: {result.get('ok')} after {result.get('dt',0):.2f}s") + blocked = (result.get("ok") is False) and (result.get("dt", 0) >= TMO_MS / 1000 - 0.2) + print("cross-thread contention: " + ("CONFIRMED" if blocked else "REFUTED")) + return 0 if blocked else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/experiments/misc_probes/exp_graph_write_throughput.py b/experiments/misc_probes/exp_graph_write_throughput.py new file mode 100644 index 00000000..9aecb608 --- /dev/null +++ b/experiments/misc_probes/exp_graph_write_throughput.py @@ -0,0 +1,181 @@ +"""Experiment: graph write throughput — per-node transaction vs batched. + +Hypothesis (from research, 2026-09-25): + PropertyGraph.add_node/add_edge open ONE SQLite transaction + acquire the + Windows named mutex PER node/edge (graph.py:526-544, 816-851). For a code + index of ~14k symbols + edges this serializes the parse workers and makes + graph building I/O-bound at ~5% CPU with constant ~3 MB/s disk. + + Industry baseline (voidstar.tech): separate transaction per row = 429 rows/s + vs one transaction + reused statement = 2.457M rows/s (~5700x). + +Arms (same session, fresh temp DB each, identical INSERT SQL): + A: PropertyGraph.add_node() per node (named mutex + BEGIN/commit + SELECT back) + B: raw sqlite3, ONE transaction, reused stmt (the batched fix) + C: raw sqlite3, per-node transaction, no mutex (isolates transaction cost from mutex) + +Negative control: node/edge counts must be equal across arms (no loss/corruption). + +Run: experiments/misc_probes/exp_graph_write_throughput.py +""" +from __future__ import annotations + +import sqlite3 +import sys +import tempfile +import time +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from src.core.graph import PropertyGraph # noqa: E402 + +N_NODES = 2000 +N_EDGES = 4000 + +INSERT_NODE = ( + "INSERT INTO nodes (name, label, qualified_name, file_path, properties) " + "VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(qualified_name) DO UPDATE SET " + "name=excluded.name, label=excluded.label, file_path=excluded.file_path, " + "properties=excluded.properties" +) +INSERT_EDGE = ( + "INSERT INTO edges (source_id, target_id, type, weight, properties) " + "VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(source_id, target_id, type) DO UPDATE SET " + "weight=excluded.weight, properties=excluded.properties" +) + + +def _tmpdb(tag: str) -> Path: + d = Path(tempfile.mkdtemp(prefix=f"graphexp_{tag}_")) + return d / "graph.db" + + +def _counts(db: Path) -> tuple[int, int]: + c = sqlite3.connect(str(db)) + try: + n = c.execute("SELECT COUNT(*) FROM nodes").fetchone()[0] + e = c.execute("SELECT COUNT(*) FROM edges").fetchone()[0] + return n, e + finally: + c.close() + + +def arm_a() -> tuple[float, int, int]: + db = _tmpdb("A") + pg = PropertyGraph(db) + t0 = time.perf_counter() + for i in range(N_NODES): + pg.add_node(name=f"n{i}", label="Function", qualified_name=f"p.f{i}", + file_path="f.py", properties={"line": i}) + for i in range(N_EDGES): + s = i % N_NODES + t = (i + 1) % N_NODES + pg.add_edge(source_qname=f"p.f{s}", target_qname=f"p.f{t}", type="CALLS") + dt = time.perf_counter() - t0 + n, e = _counts(db) + return dt, n, e + + +def arm_d() -> tuple[float, int, int]: + """The actual fix: PropertyGraph.batch() — one tx for all node/edge writes.""" + db = _tmpdb("D") + pg = PropertyGraph(db) + t0 = time.perf_counter() + with pg.batch(): + for i in range(N_NODES): + pg.add_node(name=f"n{i}", label="Function", qualified_name=f"p.f{i}", + file_path="f.py", properties={"line": i}) + for i in range(N_EDGES): + s = i % N_NODES + t = (i + 1) % N_NODES + pg.add_edge(source_qname=f"p.f{s}", target_qname=f"p.f{t}", type="CALLS") + dt = time.perf_counter() - t0 + n, e = _counts(db) + return dt, n, e + + +def _raw_arms(one_transaction: bool) -> tuple[float, int, int]: + tag = "B" if one_transaction else "C" + db = _tmpdb(tag) + pg = PropertyGraph(db) # schema is created lazily on first connection + pg.count_nodes() # force schema init + del pg + conn = sqlite3.connect(str(db)) + conn.execute("PRAGMA journal_mode=WAL") + t0 = time.perf_counter() + if one_transaction: + conn.execute("BEGIN IMMEDIATE") + for i in range(N_NODES): + if not one_transaction: + conn.execute("BEGIN IMMEDIATE") + conn.execute(INSERT_NODE, (f"n{i}", "Function", f"p.f{i}", "f.py", '{"line": %d}' % i)) + if not one_transaction: + conn.commit() + if one_transaction: + conn.commit() + # edges: same one-transaction / per-edge policy + if one_transaction: + conn.execute("BEGIN IMMEDIATE") + ids = dict(conn.execute("SELECT qualified_name, id FROM nodes").fetchall()) + for i in range(N_EDGES): + s = i % N_NODES + t = (i + 1) % N_NODES + if not one_transaction: + conn.execute("BEGIN IMMEDIATE") + conn.execute(INSERT_EDGE, (ids[f"p.f{s}"], ids[f"p.f{t}"], "CALLS", 1.0, "{}")) + if not one_transaction: + conn.commit() + if one_transaction: + conn.commit() + dt = time.perf_counter() - t0 + conn.close() + n, e = _counts(db) + return dt, n, e + + +def main() -> int: + print(f"N_NODES={N_NODES} N_EDGES={N_EDGES} (same INSERT SQL, fresh temp DB per arm)") + results = {} + for label, fn in (("A per-call PropertyGraph (mutex+txn)", arm_a), + ("B raw, ONE transaction+reused", lambda: _raw_arms(True)), + ("C raw, per-node transaction", lambda: _raw_arms(False)), + ("D PropertyGraph.batch() [the fix]", arm_d)): + dt, n, e = fn() + ent = n + e + results[label] = (dt, n, e) + print(f" {label:38s}: {dt:7.2f}s {ent/max(dt,1e-9):9.0f} entities/s (nodes={n} edges={e})") + + a_dt = results["A per-call PropertyGraph (mutex+txn)"][0] + b_dt = results["B raw, ONE transaction+reused"][0] + d_dt = results["D PropertyGraph.batch() [the fix]"][0] + print(f"\n speedup B/A = {a_dt/max(b_dt,1e-9):.1f}x") + print(f" speedup D/A (the fix) = {a_dt/max(d_dt,1e-9):.1f}x") + print(f" speedup B/C = {results['C raw, per-node transaction'][0]/max(b_dt,1e-9):.1f}x " + f"(isolates per-row commit cost)") + + # Negative control: identical entity counts (no loss/corruption). + counts = {(n, e) for (_, n, e) in results.values()} + ok = len(counts) == 1 and all(n == N_NODES for (n, _) in counts) + print(f" negative control counts equal: {ok} -> {sorted(counts)}") + print("VERDICT:", "CONFIRMED — per-entity transaction/mutex is the bottleneck" + if a_dt / max(b_dt, 1e-9) >= 5 else "INCONCLUSIVE") + return 0 if ok else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/experiments/misc_probes/exp_in_situ_agentic_timeout.py b/experiments/misc_probes/exp_in_situ_agentic_timeout.py new file mode 100644 index 00000000..5241ac98 --- /dev/null +++ b/experiments/misc_probes/exp_in_situ_agentic_timeout.py @@ -0,0 +1,81 @@ +"""In-situ control #2 (REGRESSION): real `agentic_code_search` sync wrapper. + +Calls the ACTUAL method (agentic_search.py) with a hanging +`agentic_code_search_async`, inside a running event loop. + +Pre-fix (2026-09-25): returned after ~HANG seconds (the `with ThreadPoolExecutor` +__exit__ joined the hung worker) -> timeout was dead. +Post-fix: must return the default within the timeout, abandoning a daemon worker. +The hardcoded timeout is capped to CAP (via run_bounded) to keep the control short. + +Run: PYTHONPATH=src python experiments/misc_probes/exp_in_situ_agentic_timeout.py +""" +from __future__ import annotations + +import asyncio +import sys +import time +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from src.core.search.agentic_search import AgenticSearchMixin # noqa: E402 +import src.core.run_bounded as _rb # noqa: E402 + +HANG = 5.0 +CAP = 1.0 +MARGIN = 2.0 + + +class _Probe(AgenticSearchMixin): + """Bare instance: only what agentic_code_search needs.""" + + +async def _hang_async(*args, **kwargs): + await asyncio.sleep(HANG) + return [], {} + + +def main() -> int: + inst = _Probe() + inst.agentic_code_search_async = _hang_async + + _orig_rb = _rb.run_bounded + + def _capped(fn, timeout, *, label="call", default=None): + return _orig_rb(fn, min(timeout, CAP), label=label, default=default) + + _rb.run_bounded = _capped + try: + async def _inner(): + t0 = time.perf_counter() + res = inst.agentic_code_search("probe query") + return time.perf_counter() - t0, res + + dt, res = asyncio.run(_inner()) + finally: + _rb.run_bounded = _orig_rb + + print(f"hang={HANG}s effective_timeout={CAP}s") + print(f"actual return: {dt:.2f}s result={res!r}") + bounded = dt < CAP + MARGIN + print("in-situ #2 (agentic_code_search): " + + ("BOUNDED ✅ (abandoned hung worker)" if bounded + else "NOT BOUNDED ❌ (timeout dead)")) + return 0 if bounded else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/experiments/misc_probes/exp_in_situ_parse_timeout.py b/experiments/misc_probes/exp_in_situ_parse_timeout.py new file mode 100644 index 00000000..8cae072a --- /dev/null +++ b/experiments/misc_probes/exp_in_situ_parse_timeout.py @@ -0,0 +1,99 @@ +"""In-situ control #3 (REGRESSION): real IndexProjectRunner.run() parse phase. + +Target: index_project_runner.py parse phase. +Pre-fix: `fut.result()` had NO timeout -> a hung `_parse_file_only` blocked the +phase forever (control: run() not returned within watchdog 6s). +Post-fix: each file is bounded by `run_bounded(..., MSCODEBASE_PARSE_TIMEOUT_SEC)` +so the phase completes even with a hung parser. + +For a short control we set the per-file bound to 1s via env. If run() returns +within the watchdog, the phase is BOUNDED. + +Run: PYTHONPATH=src python experiments/misc_probes/exp_in_situ_parse_timeout.py +""" +from __future__ import annotations + +import os +import sys +import tempfile +import threading +import time +import types +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +os.environ["MSCODEBASE_PARSE_TIMEOUT_SEC"] = "1" + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from src.core.indexing.index_project_runner import IndexProjectRunner # noqa: E402 + +WATCHDOG = 8.0 + + +class _FakeDBM: + def __init__(self): + self._lock = threading.Lock() + + def begin_write(self): + return self._lock + + +def main() -> int: + tmp = Path(tempfile.mkdtemp(prefix="parse_hang_")) + (tmp / "a.py").write_text("x = 1\n", encoding="utf-8") + + runner = object.__new__(IndexProjectRunner) # bypass __init__ + runner.path_manager = types.SimpleNamespace(is_safe_to_process=lambda p: True) + runner.db_manager = _FakeDBM() + runner.file_guard = types.SimpleNamespace( + should_skip_dir=lambda d: False, should_skip_file=lambda f: False, + ) + runner.table = None + runner._notification_broker = None + runner._last_reported_progress = -1 + runner._verify_and_repair_table_integrity = lambda: None + + def _hang_parse(*args, **kwargs): + time.sleep(3600) # a hung tree-sitter / file read + + runner._parse_file_only = _hang_parse + + done = threading.Event() + + def _run(): + try: + runner.run(tmp, progress_callback=lambda *a: None) + except Exception: + pass + finally: + done.set() + + th = threading.Thread(target=_run, daemon=True) + t0 = time.perf_counter() + th.start() + returned = done.wait(WATCHDOG) + dt = time.perf_counter() - t0 + + print(f"watchdog={WATCHDOG}s per-file bound=1s parse worker hangs=3600s") + print(f"run() returned within watchdog: {returned} (after {dt:.2f}s)") + if returned: + print("in-situ #3 (parse phase): BOUNDED ✅ (hung file abandoned)") + return 0 + print("in-situ #3 (parse phase): NOT BOUNDED ❌ (phase frozen)") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/experiments/misc_probes/exp_index_dedup_probe.py b/experiments/misc_probes/exp_index_dedup_probe.py new file mode 100644 index 00000000..eb8ac841 --- /dev/null +++ b/experiments/misc_probes/exp_index_dedup_probe.py @@ -0,0 +1,106 @@ +"""Decisive probe: is the index bloated by duplicates? + +Snapshot the live LanceDB dir and count total rows vs unique file_path / chunk +text. If unique ~= total -> no bloat (the count is legitimate). If unique << total +-> duplicates (bloat). Read-only: works on a COPY, never the live DB. + +Run: experiments/misc_probes/exp_index_dedup_probe.py +""" +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + +def main() -> int: + if len(sys.argv) < 2: + print("usage: exp_index_dedup_probe.py ") + return 2 + src = Path(sys.argv[1]) + if not src.exists(): + print(f"not found: {src}") + return 2 + + tmp = Path(tempfile.mkdtemp(prefix="idxsnap_")) + dst = tmp / src.name + shutil.copytree(src, dst) + print(f"snapshot: {dst}") + + import lancedb + + db = lancedb.connect(str(dst)) + names = list(db.table_names()) + print(f"tables: {names}") + if not names: + print("no tables") + return 1 + t = db.open_table(names[0]) + n = t.count_rows() + print(f"total rows: {n}") + + df = t.to_arrow().to_pandas() + cols = list(df.columns) + print(f"columns: {cols}") + + for key in ("file_path", "text", "chunk_hash", "content_hash", "file_hash"): + if key in df.columns: + uniq = df[key].nunique(dropna=False) + dups = n - uniq + print(f" {key}: unique={uniq} duplicates={dups} ({dups/max(n,1)*100:.1f}%)") + + # Exact duplicate chunk texts (within the same file) = real bloat signal. + if "text" in df.columns and "file_path" in df.columns: + key2 = df["file_path"].astype(str) + "\u0000" + df["text"].astype(str) + dup2 = int(key2.duplicated().sum()) + print(f" (file_path+text) duplicate rows: {dup2} ({dup2/max(n,1)*100:.1f}%)") + + # THE bloat test: same file, same position indexed twice = stale version kept. + for combo in (("file_path", "chunk_index"), ("file_path", "chunk_hash"), + ("file_path", "start_line", "end_line")): + if all(k in df.columns for k in combo): + s = df[list(combo)].astype(str).agg("\u0000".join, axis=1) + d = int(s.duplicated().sum()) + print(f" dup{combo}: {d} ({d/max(n,1)*100:.1f}%)") + + # Path normalisation: same file under "\" and "/" = duplicated rows (bloat). + if "file_path" in df.columns: + norm = df["file_path"].astype(str).str.replace("\\", "/", regex=False) + raw_u, norm_u = int(df["file_path"].nunique()), int(norm.nunique()) + print(f"\n file_path distinct: raw={raw_u} normalized={norm_u} " + f"-> path-duplication={raw_u - norm_u}") + ext = norm.str.rsplit(".", n=1).str[-1] + print(" top extensions by rows:") + for e, c in ext.value_counts().head(8).items(): + print(f" .{e}: {c}") + + # Show WHY spans repeat: example rows sharing (file_path, start_line, end_line). + if all(k in df.columns for k in ("file_path", "start_line", "end_line", "text")): + span = df.groupby(["file_path", "start_line", "end_line"]).size() + hot = span[span > 1].sort_values(ascending=False).head(3) + for (fp, sl, el), cnt in hot.items(): + sub = df[(df["file_path"] == fp) & (df["start_line"] == sl) + & (df["end_line"] == el)] + kinds = sub.get("symbol_type", sub.get("source")) + print(f"\n EXAMPLE {fp} [{sl}-{el}] x{cnt}:") + for _, r in sub.head(4).iterrows(): + t = str(r["text"])[:70].replace("\n", " ") + print(f" idx={r.get('chunk_index')} hash={str(r.get('chunk_hash'))[:8]} " + f"len={len(str(r['text']))} :: {t}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/experiments/misc_probes/exp_ivf_guard_negative_control.py b/experiments/misc_probes/exp_ivf_guard_negative_control.py new file mode 100644 index 00000000..a8b6f139 --- /dev/null +++ b/experiments/misc_probes/exp_ivf_guard_negative_control.py @@ -0,0 +1,85 @@ +"""Negative control for the IVF finalize timeout guard (in-code, in-situ). + +Injects a `table.optimize` that blocks longer than the timeout and asserts that +`IndexProjectRunner._safe_ivf_index` returns within timeout + margin. + +On the CURRENT code this MUST FAIL (the guard cannot fire): the `finally: +shutdown(wait=True)` joins the blocked worker. After the fix it MUST PASS. +This is the regression test that proves the guard can fail. + +Run: PYTHONPATH=src python experiments/misc_probes/exp_ivf_guard_negative_control.py +""" +from __future__ import annotations + +import contextlib +import sys +import time +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +TIMEOUT = 1.0 +BLOCK = 5.0 +MARGIN = 2.0 # accept up to timeout + margin + +try: + from src.core.indexing.index_project_runner import IndexProjectRunner +except Exception as exc: # pragma: no cover + import traceback + traceback.print_exc() + print(f"IMPORT FAILED: {exc}") + sys.exit(2) + + +class BlockingTable: + """Stands in for the LanceDB table whose native optimize() hangs.""" + + def optimize(self, *a, **k): # the "hung" native call + time.sleep(BLOCK) + return None + + def list_indices(self): + return [] + + def create_index(self, *a, **k): + return None + + def count_rows(self): + return 0 + + +def main() -> int: + runner = object.__new__(IndexProjectRunner) # bypass __init__ + runner.table = BlockingTable() + runner._suspend_write_lock = lambda: contextlib.nullcontext() + + t0 = time.perf_counter() + runner._safe_ivf_index(timeout=TIMEOUT) + elapsed = time.perf_counter() - t0 + + print(f"configured timeout : {TIMEOUT:.1f}s") + print(f"simulated hang : {BLOCK:.1f}s") + print(f"actual return : {elapsed:.2f}s") + limit = TIMEOUT + MARGIN + ok = elapsed < limit + print(f"guard fired (< {limit:.1f}s): {'PASS' if ok else 'FAIL'}") + if not ok: + print("=> CONFIRMED BUG: timeout guard cannot bound the hung call " + "(shutdown(wait=True) joins it).") + return 0 if ok else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/experiments/misc_probes/exp_timeout_cancel_mechanism.py b/experiments/misc_probes/exp_timeout_cancel_mechanism.py new file mode 100644 index 00000000..79ae755c --- /dev/null +++ b/experiments/misc_probes/exp_timeout_cancel_mechanism.py @@ -0,0 +1,138 @@ +"""Experiment: does an in-thread timeout actually bound a non-cancellable call? + +Reproduces the mechanism behind the IVF finalize hang: + - `Future.result(timeout=)` does NOT stop a running thread; + - `ThreadPoolExecutor.shutdown(wait=True)` (in a finally) JOINS that thread; + - hence a "timeout guard" that ends in wait=True cannot fire. + +HYPOTHESIS (H1): with a worker that sleeps longer than the timeout, + (A) result(timeout=T) raises after ~T while the worker KEEPS running; + (B) shutdown(wait=True) then blocks for the REMAINING worker time (the hang); + (C) shutdown(wait=False) returns immediately. +HYPOTHESIS (H2): real bounding requires a daemon thread (abandon) or a + separate process with hard kill. + +Run: python experiments/misc_probes/exp_timeout_cancel_mechanism.py +""" +from __future__ import annotations + +import sys +import threading +import time + +if sys.stdout.encoding != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +WORK = 6.0 # seconds the "native call" runs +TMO = 1.0 # the timeout we pretend to enforce + + +def _busy_box(tag: str, seconds: float = WORK) -> None: + time.sleep(seconds) + + +def part_a_result_timeout_does_not_cancel() -> tuple[float, bool, float]: + from concurrent.futures import ThreadPoolExecutor + + ex = ThreadPoolExecutor(max_workers=1) + t0 = time.perf_counter() + fut = ex.submit(_busy_box, "A") + timed_out = False + try: + fut.result(timeout=TMO) + except Exception: + timed_out = True + t_after_result = time.perf_counter() - t0 + still_running = any(th.is_alive() for th in threading.enumerate() + if th is not threading.current_thread()) + # emulate the code's finally: + t1 = time.perf_counter() + ex.shutdown(wait=True) + t_shutdown_wait_true = time.perf_counter() - t1 + return t_after_result, still_running, t_shutdown_wait_true + + +def part_c_shutdown_no_wait() -> float: + from concurrent.futures import ThreadPoolExecutor + + ex = ThreadPoolExecutor(max_workers=1) + ex.submit(_busy_box, "C", 20.0) # deliberately longer than the test + time.sleep(0.2) + t1 = time.perf_counter() + ex.shutdown(wait=False) + return time.perf_counter() - t1 + + +def part_d_daemon_thread_abandon() -> tuple[float, bool]: + t = threading.Thread(target=_busy_box, args=("D", 20.0), daemon=True) + t.start() + t0 = time.perf_counter() + t.join(timeout=TMO) + return time.perf_counter() - t0, t.is_alive() + + +def _child_sleep() -> None: # pragma: no cover - subprocess entry + time.sleep(60) + + +def part_e_process_hard_kill() -> float: + import multiprocessing as mp + + ctx = mp.get_context("spawn") + p = ctx.Process(target=_child_sleep) + p.start() + t0 = time.perf_counter() + p.join(timeout=TMO) + killed = False + if p.is_alive(): + p.terminate() + p.join(timeout=5) + killed = True + dt = time.perf_counter() - t0 + assert killed, "process should have been killed" + return dt + + +def main() -> int: + print(f"config: work={WORK}s timeout={TMO}s\n") + + a_t, a_alive, a_shutdown = part_a_result_timeout_does_not_cancel() + print("A) result(timeout) then finally shutdown(wait=True) [CURRENT CODE]") + print(f" raised timeout after : {a_t:.2f}s (expected ~{TMO}s)") + print(f" worker still running : {a_alive}") + print(f" shutdown(wait=True) : blocked {a_shutdown:.2f}s (HANG)") + print(f" => total before return ~ {a_t + a_shutdown:.2f}s " + f"(should have been ~{TMO}s)\n") + + c_t = part_c_shutdown_no_wait() + print("C) shutdown(wait=False)") + print(f" returned in : {c_t:.3f}s (does not join)\n") + + d_t, d_alive = part_d_daemon_thread_abandon() + print("D) daemon thread + join(timeout)") + print(f" returned in : {d_t:.2f}s; worker alive={d_alive} " + f"(abandoned; daemon => process can exit)\n") + + e_t = part_e_process_hard_kill() + print("E) separate process + terminate() on timeout") + print(f" returned in : {e_t:.2f}s; child killed=True\n") + + verdict_h1 = (a_t < 2.0) and a_alive and (a_shutdown > 2.0) + verdict_h2 = (d_t < 2.0) and (e_t < 2.5) + print(f"H1 (timeout cannot cancel; wait=True hangs): " + f"{'CONFIRMED' if verdict_h1 else 'REFUTED'}") + print(f"H2 (daemon/process bound the call): " + f"{'CONFIRMED' if verdict_h2 else 'REFUTED'}") + return 0 if (verdict_h1 and verdict_h2) else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/experiments/misc_probes/exp_timeout_class_audit.py b/experiments/misc_probes/exp_timeout_class_audit.py new file mode 100644 index 00000000..b2155bca --- /dev/null +++ b/experiments/misc_probes/exp_timeout_class_audit.py @@ -0,0 +1,165 @@ +"""Class audit: "in-thread timeout that cannot bound a non-cancellable call". + +For each observed instance in src/, this reproduces the EXACT code shape in a +bounded control and asks: does the caller return within timeout+margin, or does +it block for the full simulated hang? A guard that does not fire is the bug. + +Instances (source map): + #1 index_project_runner.py:671-689 _safe_optimize -> FIXED (daemon helper) + #2 agentic_search.py:551-562 with pool + result(timeout=60) + #3 index_project_runner.py:360-365 pool.submit + fut.result() (no timeout) + #4 error_handler.py:649-654 shared pool + result + future.cancel() + #5 engine.py:48/505, health.py:145 non-daemon pools (atexit join risk) + +Run: python experiments/misc_probes/exp_timeout_class_audit.py +""" +from __future__ import annotations + +import concurrent.futures as cf +import sys +import threading +import time + +if sys.stdout.encoding != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +HANG = 5.0 # simulated hung worker +TMO = 1.0 # the timeout the code claims to enforce +MARGIN = 2.0 # allowed overshoot + + +def _hang(): + time.sleep(HANG) + + +def c1_fixed_helper() -> tuple[float, bool]: + """The fixed pattern: daemon thread + Event.wait, no join.""" + done = threading.Event() + + def w(): + try: + _hang() + finally: + done.set() + + th = threading.Thread(target=w, daemon=True) + th.start() + t0 = time.perf_counter() + finished = done.wait(TMO) + return time.perf_counter() - t0, finished + + +def c2_with_pool_result_timeout() -> float: + """Mirror of agentic_search.py:551-562: `with ThreadPoolExecutor as pool` + and `return pool.submit(...).result(timeout=T)`. On timeout the `with` + __exit__ calls shutdown(wait=True) and JOINS the hung worker.""" + t0 = time.perf_counter() + with cf.ThreadPoolExecutor(max_workers=1) as pool: + fut = pool.submit(_hang) + try: + fut.result(timeout=TMO) + except cf.TimeoutError: + pass + return time.perf_counter() - t0 + + +def c3_no_timeout_future_result() -> float: + """Mirror of index_project_runner.py:360-365: pool.submit + fut.result() + with NO timeout -> blocks for the whole worker duration.""" + t0 = time.perf_counter() + with cf.ThreadPoolExecutor(max_workers=1) as pool: + fut = pool.submit(_hang) + fut.result() # no timeout at all + return time.perf_counter() - t0 + + +def c4_cancel_does_not_stop_running() -> tuple[bool, bool]: + """Mirror of error_handler.py:649-654: result(timeout) then future.cancel(). + cancel() returns False for an already-running task (cannot stop it).""" + pool = cf.ThreadPoolExecutor(max_workers=1) + fut = pool.submit(_hang) + time.sleep(0.2) # ensure it started + try: + fut.result(timeout=TMO) + except cf.TimeoutError: + pass + cancelled = fut.cancel() # running -> False + alive = any(t.is_alive() and t.daemon is False + for t in threading.enumerate()) + pool.shutdown(wait=False) + return cancelled, alive + + +def c5_pool_workers_are_daemon() -> bool: + """Are ThreadPoolExecutor workers daemon? If not, concurrent.futures' + atexit `_python_exit` joins them -> a hung task blocks process exit.""" + pool = cf.ThreadPoolExecutor(max_workers=1) + got = {} + ev = threading.Event() + + def probe(): + got["daemon"] = threading.current_thread().daemon + ev.set() + + pool.submit(probe) + ev.wait(2) + pool.shutdown(wait=False) + return got.get("daemon", True) + + +def main() -> int: + print(f"config: hang={HANG}s timeout={TMO}s margin={MARGIN}s\n") + results: dict[str, bool] = {} + + dt, finished = c1_fixed_helper() + ok = (dt < TMO + MARGIN) and not finished + results["#1 fixed helper"] = not ok # verdict = "bug present" + print(f"#1 fixed helper (daemon+Event.wait): returned {dt:.2f}s " + f"finished={finished} -> {'BOUNDED ✅' if ok else 'NOT BOUNDED ❌'}") + + dt = c2_with_pool_result_timeout() + hung = dt > HANG - 0.5 + results["#2 agentic_search"] = hung + print(f"#2 agentic_search(with pool+result(timeout)): returned {dt:.2f}s " + f"-> {'JOINS HUNG WORKER (guard dead) ✅BUG' if hung else 'bounded'}") + + dt = c3_no_timeout_future_result() + unbounded = dt > HANG - 0.5 + results["#3 parse phase"] = unbounded + print(f"#3 parse phase (fut.result() no timeout): returned {dt:.2f}s " + f"-> {'UNBOUNDED ✅BUG' if unbounded else 'bounded'}") + + cancelled, alive = c4_cancel_does_not_stop_running() + bad = (cancelled is False and alive) + results["#4 error_handler cancel"] = bad + print(f"#4 error_handler (result(timeout)+cancel): cancel()={cancelled} " + f"worker_alive={alive} -> {'CANCEL CANNOT STOP RUNNING ✅BUG' if bad else 'ok'}") + + is_daemon = c5_pool_workers_are_daemon() + bad = (is_daemon is False) + results["#5 non-daemon pools"] = bad + print(f"#5 ThreadPoolExecutor worker daemon={is_daemon} -> " + f"{'NON-DAEMON (atexit join risk) ✅BUG' if bad else 'daemon (safe)'}") + + print("\n=== VERDICTS ===") + for k, v in results.items(): + print(f" {k}: {'CONFIRMED' if v else 'REFUTED'}") + # expected: #1 safe(False), #2/#3/#4/#5 confirmed issues (True) + expected = {"#1 fixed helper": False, "#2 agentic_search": True, + "#3 parse phase": True, "#4 error_handler cancel": True, + "#5 non-daemon pools": True} + consistent = all(results[k] == expected[k] for k in expected) + print(f"\naudit consistent with source inspection: {consistent}") + return 0 if consistent else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/experiments/misc_probes/run_full_reindex_headless.py b/experiments/misc_probes/run_full_reindex_headless.py new file mode 100644 index 00000000..6fd2ee81 --- /dev/null +++ b/experiments/misc_probes/run_full_reindex_headless.py @@ -0,0 +1,128 @@ +"""Headless full-reindex driver via the production MCP path. + +Starts the real mscodebase-intelligence MCP server (`python -m src.main`) as a +stdio child, then calls the SAME tools Zed would: + intel_trigger_reindex(mode="full") -> job_id + intel_get_job_status(job_id) -> poll until completed/failed +This exercises the real graph-lock + recreate_table_physical + normalized-path +reindex, without needing Zed. + +Run with the EXTENSION venv python (it has the `mcp` client): + \\venv\\Scripts\\python.exe experiments\\misc_probes\\run_full_reindex_headless.py +""" +from __future__ import annotations + +import asyncio +import os +import re +import sys +import time +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +EXT = Path(r"C:\Users\misha\AppData\Local\Zed\extensions\mscodebase-intelligence") +PY = EXT / "venv" / "Scripts" / "python.exe" +PROJECT = Path(r"D:\Project\MSCodeBase") + +LOG = Path(os.environ.get("TEMP", ".")) / "opencode" / "full_reindex_headless.log" +LOG.parent.mkdir(parents=True, exist_ok=True) + +JOB_RE = re.compile(r"Job ID:\s*`([0-9a-f]+)`") +STATUS_RE = re.compile(r"Статус:\s*`(\w+)`") +PCT_RE = re.compile(r"`(\d+)%`") +ERR_RE = re.compile(r"Ошибка:\s*(.+)") + + +def log(msg: str) -> None: + line = f"[{time.strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + with LOG.open("a", encoding="utf-8") as f: + f.write(line + "\n") + + +async def main() -> int: + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + env = dict(os.environ) + env["PYTHONPATH"] = str(EXT) + env["PROJECT_PATH"] = str(PROJECT) + env["PYTHONUTF8"] = "1" + + params = StdioServerParameters( + command=str(PY), args=["-u", "-m", "src.main"], env=env, cwd=str(EXT), + ) + + # Bounded poll budget (full reindex ~10-20 min on CPU embedder). + deadline = time.time() + float(os.environ.get("REINDEX_TIMEOUT_SEC", "2400")) + + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + log("initialize...") + await session.initialize() + + tools = [t.name for t in (await session.list_tools()).tools] + log(f"tools={len(tools)} trigger_present={'intel_trigger_reindex' in tools}") + if "intel_trigger_reindex" not in tools: + log("FAIL: intel_trigger_reindex not registered") + return 2 + + if os.environ.get("DRY_RUN") == "1": + log("DRY_RUN: plumbing OK, not triggering") + return 0 + + log("calling intel_trigger_reindex(mode=full)...") + res = await session.call_tool("intel_trigger_reindex", {"mode": "full"}) + text = "\n".join( + c.text for c in res.content if getattr(c, "type", "") == "text" + ) + log("--- trigger response ---") + for ln in text.splitlines(): + log(" " + ln) + m = JOB_RE.search(text) + if not m: + log("FAIL: no job_id parsed") + return 2 + job_id = m.group(1) + log(f"job_id={job_id}") + + last = "" + while time.time() < deadline: + await asyncio.sleep(20) + st = await session.call_tool("intel_get_job_status", {"job_id": job_id}) + stext = "\n".join( + c.text for c in st.content if getattr(c, "type", "") == "text" + ) + sm = STATUS_RE.search(stext) + status = sm.group(1) if sm else "?" + pm = PCT_RE.search(stext) + pct = pm.group(1) if pm else "?" + line = f"status={status} pct={pct}%" + em = ERR_RE.search(stext) + if em: + line += f" err={em.group(1).strip()}" + if line != last: + log(line) + last = line + if status in ("completed", "failed", "error"): + log(f"DONE: {status}") + log("--- final status ---") + for ln in stext.splitlines(): + log(" " + ln) + return 0 if status == "completed" else 1 + log("TIMEOUT waiting for job") + return 3 + + +if __name__ == "__main__": + try: + sys.exit(asyncio.run(main())) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/src/core/embedder_lease.py b/src/core/embedder_lease.py new file mode 100644 index 00000000..08cf7b3c --- /dev/null +++ b/src/core/embedder_lease.py @@ -0,0 +1,66 @@ +"""Cross-process "embedder in use" lease (2026-09-25). + +Problem: the llama.cpp embedder listens on a FIXED port and is shared by all +MSCodeBase MCP servers (one per Zed/opencode workspace). Each server's +`_watchdog_loop` idle-kills the embedder it spawned after EMBEDDER_IDLE_TIMEOUT +(120s). During a full reindex the long *parse* phase issues no embed calls, so +the embedder looks idle and is killed mid-run — then another workspace's server +grabs the freed port. That is the "servers conflict / it keeps falling" class. + +Fix: an indexing process creates a short-lived *lease* file refreshed on every +progress tick; the idle watchdog must NOT kill the embedder while any fresh +lease exists (its own reindex or another process's). + +Never raises: a recording/best-effort channel. +""" +from __future__ import annotations + +import json +import os +import tempfile +import time +from pathlib import Path +from typing import Optional + +__all__ = ["touch", "is_active", "lease_path"] + +_DEFAULT_MAX_AGE_S = 300.0 + + +def lease_path() -> Path: + d = Path(tempfile.gettempdir()) / "mscodebase_locks" + return d / "embedder.lease" + + +def touch(reason: str = "") -> None: + """Mark the shared embedder as in-use right now. Best-effort.""" + try: + p = lease_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text( + json.dumps({"ts": time.time(), "pid": os.getpid(), "reason": reason}), + encoding="utf-8", + ) + except Exception: # noqa: BLE001 — never break indexing over a lease write + pass + + +def is_active(max_age_s: float = _DEFAULT_MAX_AGE_S) -> bool: + """True if some process recently declared the embedder in use.""" + try: + p = lease_path() + if not p.exists(): + return False + data = json.loads(p.read_text(encoding="utf-8")) + ts = float(data.get("ts", 0)) + return (time.time() - ts) < max_age_s + except Exception: # noqa: BLE001 — corrupt/missing lease = not active + return False + + +def holder() -> Optional[dict]: + """Return the lease payload (diagnostics), or None.""" + try: + return json.loads(lease_path().read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + return None diff --git a/src/core/error_envelope.py b/src/core/error_envelope.py new file mode 100644 index 00000000..c9bf8bed --- /dev/null +++ b/src/core/error_envelope.py @@ -0,0 +1,122 @@ +"""Typed, actionable error envelope for MCP tools. + +Every tool failure should carry a stable ``code``, a human ``message``, and a +``next_action`` hint so an agent can branch and recover without guessing +(research 2026-09-25: Orisu/Perplexity/xAI MCP envelopes, GitHub error style +guide, LiveMCP-101 failure taxonomy). + +Shape:: + + {"ok": false, "error": {"code": "...", "message": "...", + "next_action": "...", "details": {...}}} + +Dependency-free (stdlib only) so any layer can import it without cycles. +""" +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any, Optional + +__all__ = ["ErrorCode", "ToolError", "tool_error", "error_json", "playbook"] + + +class ErrorCode(str, Enum): + VALIDATION = "VALIDATION_FAILED" + NOT_FOUND = "NOT_FOUND" + AUTH = "AUTH_REQUIRED" + TIMEOUT = "TIMEOUT" + RATE_LIMIT = "RATE_LIMIT" + INDEX_NOT_READY = "INDEX_NOT_READY" + REINDEX_IN_PROGRESS = "REINDEX_IN_PROGRESS" + DEPENDENCY_DOWN = "DEPENDENCY_DOWN" + CONFLICT = "CONFLICT" + INTERNAL = "INTERNAL_ERROR" + + +# The "what to do about it" for each code. Agents branch on `code`; humans read +# `next_action`. Keep each hint concrete and single-step. +_PLAYBOOK: dict[ErrorCode, str] = { + ErrorCode.VALIDATION: "Fix the arguments and retry.", + ErrorCode.NOT_FOUND: ( + "Verify the identifier. If the file is new/unchanged index is stale, " + "call intel_trigger_reindex(mode='incremental') and retry after completion." + ), + ErrorCode.AUTH: ( + "Set/refresh the credential in .env and restart the MCP. Do NOT blind-retry." + ), + ErrorCode.TIMEOUT: ( + "Bounded operation exceeded its limit. Retry once; if it repeats, reduce " + "scope (fewer files / smaller limit) or raise the relevant *_TIMEOUT env." + ), + ErrorCode.RATE_LIMIT: "Back off; the server already retries with backoff.", + ErrorCode.INDEX_NOT_READY: ( + "Index is empty or building. Call intel_trigger_reindex(mode='incremental') " + "and poll intel_get_job_status until 'completed'." + ), + ErrorCode.REINDEX_IN_PROGRESS: ( + "A reindex is running; searches fast-fail by design. Poll " + "intel_get_job_status(job_id) and retry when it completes." + ), + ErrorCode.DEPENDENCY_DOWN: ( + "Embedder/reranker unavailable. The server auto-revives it; retry in a " + "few seconds (first revive can take ~18s)." + ), + ErrorCode.CONFLICT: "Re-read the latest state, reconcile, and retry.", + ErrorCode.INTERNAL: ( + "Retry once. If it persists, call get_logs and report details.request_id." + ), +} + + +@dataclass +class ToolError: + code: str + message: str + next_action: str + details: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return {"ok": False, "error": asdict(self)} + + def to_json(self) -> str: + return json.dumps(self.to_dict(), ensure_ascii=False) + + +def playbook(code: ErrorCode | str) -> str: + """Return the canonical next_action for a code (raises on unknown).""" + if not isinstance(code, ErrorCode): + code = ErrorCode(code) + return _PLAYBOOK[code] + + +def tool_error( + code: ErrorCode | str, + message: str, + *, + detail: str = "", + details: Optional[dict] = None, + next_action: Optional[str] = None, +) -> ToolError: + """Build a typed, actionable error. + + Args: + code: one of ErrorCode (or its value). + message: human-readable cause. + detail: extra context appended to the playbook hint. + details: machine-readable extras (tool, missing, present, ...). + next_action: override the playbook hint (use sparingly). + """ + if not isinstance(code, ErrorCode): + code = ErrorCode(code) # raises ValueError on unknown code + hint = next_action if next_action is not None else _PLAYBOOK[code] + if detail: + hint = f"{hint} ({detail})" + return ToolError(code=code.value, message=message, next_action=hint, + details=details or {}) + + +def error_json(code: ErrorCode | str, message: str, **kwargs: Any) -> str: + """Convenience: ``tool_error(...).to_json()``.""" + return tool_error(code, message, **kwargs).to_json() diff --git a/src/core/error_handler.py b/src/core/error_handler.py index 31e465b1..a6d18f63 100644 --- a/src/core/error_handler.py +++ b/src/core/error_handler.py @@ -645,13 +645,21 @@ def sync_wrapper(*args, **kwargs) -> str: except RuntimeError: loop = None if loop is not None: - # Мы внутри async контекста — submit в пул с timeout - future = _SYNC_POOL.submit(func, *args, **kwargs) - try: - result = future.result(timeout=timeout_ms / 1000.0) - except TimeoutError: - future.cancel() - raise + # Bound without joining (timeout-class audit #4, 2026-09-25): + # a hung sync tool must not leak a non-daemon pool worker. + from src.core.run_bounded import run_bounded + + _sentinel = object() + result = run_bounded( + lambda: func(*args, **kwargs), + timeout_ms / 1000.0, + label=tool_name, + default=_sentinel, + ) + if result is _sentinel: + raise TimeoutError( + f"{tool_name} exceeded {timeout_ms}ms" + ) else: result = func(*args, **kwargs) else: diff --git a/src/core/graph.py b/src/core/graph.py index c7397d90..9dd1d5f1 100644 --- a/src/core/graph.py +++ b/src/core/graph.py @@ -115,17 +115,48 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.release() +# In-process (thread) lock per db_path. A Windows named mutex is owned by the +# acquiring THREAD, so two threads of the SAME process contend and one times out +# (2026-09-25: reindex finalize failed with "Could not acquire cross-process lock +# ... within 30000ms" while a concurrent graph op held it). Serialise threads +# locally first — the named mutex then only arbitrates CROSS-process access. +_LOCAL_GRAPH_LOCKS: Dict[str, threading.RLock] = {} +_LOCAL_GRAPH_LOCKS_GUARD = threading.Lock() + + +def _local_graph_lock(db_path: Path) -> threading.RLock: + """One RLock per resolved graph.db path (threads in this process).""" + key = str(Path(db_path).resolve()) + with _LOCAL_GRAPH_LOCKS_GUARD: + lock = _LOCAL_GRAPH_LOCKS.get(key) + if lock is None: + lock = threading.RLock() + _LOCAL_GRAPH_LOCKS[key] = lock + return lock + + @contextmanager def _cross_process_lock(db_path: Path, timeout_ms: int = 30000) -> Generator[None, None, None]: - """Контекстный менеджер для cross-process блокировки.""" - mutex = _CrossProcessMutex(db_path) - acquired = mutex.acquire(timeout_ms) - if not acquired: - raise sqlite3.OperationalError(f"Could not acquire cross-process lock for {db_path} within {timeout_ms}ms") + """Thread-local + cross-process lock for graph.db. + + Threads in THIS process serialise on a local RLock (no spurious timeout); + the named mutex then only arbitrates between PROCESSES. + """ + local = _local_graph_lock(db_path) + local.acquire() try: - yield + mutex = _CrossProcessMutex(db_path) + acquired = mutex.acquire(timeout_ms) + if not acquired: + raise sqlite3.OperationalError( + f"Could not acquire cross-process lock for {db_path} within {timeout_ms}ms" + ) + try: + yield + finally: + mutex.release() finally: - mutex.release() + local.release() # ─── Retry Decorator для SQLite OperationalError ────────────────── @@ -369,6 +400,54 @@ def __init__(self, db_path: Union[str, Path]): self._lock = threading.RLock() self._conn: Optional["sqlite3.Connection"] = None self._open_count = 0 # для reopen после закрытия + # Batch mode (2026-09-25): when active, add_node/add_edge/delete_node + # reuse ONE connection + ONE transaction instead of a named-mutex + + # BEGIN/commit PER row (measured 142 -> ~67k entities/s, E18). + # The batch is owned by the thread that opened it (thread-aware): + # another thread blocks on the locks and gets its own batch. Without + # this, concurrent files (4 parse workers) shared one transaction and + # corrupted each other ("cannot start a transaction within a tx"). + self._batch_conn: Optional["sqlite3.Connection"] = None + self._batch_depth = 0 + self._batch_owner: Optional[int] = None + + @contextmanager + def batch(self): + """Coalesce all node/edge writes in the block into one transaction. + + Takes the cross-process lock and opens a single BEGIN IMMEDIATE once; + nested calls from the SAME thread reuse the transaction. A DIFFERENT + thread blocks on the locks and gets its own batch (thread-aware), so + concurrent files never share one transaction. Commits on success, rolls + back the whole block on error (atomic). Reentrant (same thread). + """ + me = threading.get_ident() + if self._batch_depth > 0 and self._batch_owner == me: + self._batch_depth += 1 + try: + yield self + finally: + self._batch_depth -= 1 + return + + with _cross_process_lock(self._db_path): + with self._lock: + conn = self._get_conn() + conn.execute("BEGIN IMMEDIATE") + self._batch_conn = conn + self._batch_depth = 1 + self._batch_owner = me + try: + yield self + conn.commit() + except Exception: + conn.rollback() + raise + finally: + self._batch_conn = None + self._batch_depth = 0 + self._batch_owner = None + # ── Управление подключением ──────────────────────────── @@ -491,34 +570,48 @@ def add_node( qname = qualified_name or name props_json = json.dumps(properties or {}, ensure_ascii=False) + if ( + self._batch_conn is not None + and self._batch_owner == threading.get_ident() + ): # batched: caller owns the transaction + return self._write_node( + self._batch_conn, name, label, qname, file_path, props_json + ) + # Cross-process lock + retry для записи with _cross_process_lock(self._db_path): with self._lock: conn = self._get_conn() conn.execute("BEGIN IMMEDIATE") try: - conn.execute( - """INSERT INTO nodes (name, label, qualified_name, file_path, properties) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(qualified_name) DO UPDATE SET - name=excluded.name, - label=excluded.label, - file_path=excluded.file_path, - properties=excluded.properties""", - (name, label, qname, file_path, props_json), + node = self._write_node( + conn, name, label, qname, file_path, props_json ) conn.commit() except Exception: conn.rollback() raise - - # Возвращаем созданную запись - row = conn.execute( - "SELECT id, name, label, qualified_name, file_path, properties " - "FROM nodes WHERE qualified_name = ?", - (qname,), - ).fetchone() - return Node.from_row(row) + return node + + @staticmethod + def _write_node(conn, name, label, qname, file_path, props_json) -> "Node": + """Insert/update one node on an already-open transaction.""" + conn.execute( + """INSERT INTO nodes (name, label, qualified_name, file_path, properties) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(qualified_name) DO UPDATE SET + name=excluded.name, + label=excluded.label, + file_path=excluded.file_path, + properties=excluded.properties""", + (name, label, qname, file_path, props_json), + ) + row = conn.execute( + "SELECT id, name, label, qualified_name, file_path, properties " + "FROM nodes WHERE qualified_name = ?", + (qname,), + ).fetchone() + return Node.from_row(row) def get_node(self, qualified_name: str) -> Optional[Node]: """Получает узел по qualified_name.""" @@ -549,6 +642,14 @@ def delete_node(self, qualified_name: str) -> bool: True если узел был удалён """ # Cross-process lock + retry для записи + if ( + self._batch_conn is not None + and self._batch_owner == threading.get_ident() + ): # batched: caller owns the transaction + cur = self._batch_conn.execute( + "DELETE FROM nodes WHERE qualified_name = ?", (qualified_name,) + ) + return cur.rowcount > 0 with _cross_process_lock(self._db_path): with self._lock: conn = self._get_conn() @@ -781,43 +882,60 @@ def add_edge( """ props_json = json.dumps(properties or {}, ensure_ascii=False) + if ( + self._batch_conn is not None + and self._batch_owner == threading.get_ident() + ): # batched: caller owns the transaction + return self._write_edge( + self._batch_conn, source_qname, target_qname, type, weight, props_json + ) + # Cross-process lock + retry для записи with _cross_process_lock(self._db_path): with self._lock: conn = self._get_conn() conn.execute("BEGIN IMMEDIATE") try: - # Находим ID узлов (или создаём заглушки) - source = conn.execute( - "SELECT id FROM nodes WHERE qualified_name = ?", (source_qname,) - ).fetchone() - target = conn.execute( - "SELECT id FROM nodes WHERE qualified_name = ?", (target_qname,) - ).fetchone() - - if not source or not target: + edge = self._write_edge( + conn, source_qname, target_qname, type, weight, props_json + ) + if edge is None: conn.rollback() return None - - conn.execute( - """INSERT INTO edges (source_id, target_id, type, weight, properties) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(source_id, target_id, type) DO UPDATE SET - weight=excluded.weight, - properties=excluded.properties""", - (source[0], target[0], type, weight, props_json), - ) conn.commit() except Exception: conn.rollback() raise + return edge - row = conn.execute( - "SELECT id, source_id, target_id, type, weight, properties " - "FROM edges WHERE source_id = ? AND target_id = ? AND type = ?", - (source[0], target[0], type), - ).fetchone() - return Edge.from_row(row) if row else None + @staticmethod + def _write_edge(conn, source_qname, target_qname, type, weight, props_json): + """Insert/update one edge on an already-open transaction. + + Returns None if either endpoint node is missing (caller decides tx fate). + """ + source = conn.execute( + "SELECT id FROM nodes WHERE qualified_name = ?", (source_qname,) + ).fetchone() + target = conn.execute( + "SELECT id FROM nodes WHERE qualified_name = ?", (target_qname,) + ).fetchone() + if not source or not target: + return None + conn.execute( + """INSERT INTO edges (source_id, target_id, type, weight, properties) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, target_id, type) DO UPDATE SET + weight=excluded.weight, + properties=excluded.properties""", + (source[0], target[0], type, weight, props_json), + ) + row = conn.execute( + "SELECT id, source_id, target_id, type, weight, properties " + "FROM edges WHERE source_id = ? AND target_id = ? AND type = ?", + (source[0], target[0], type), + ).fetchone() + return Edge.from_row(row) if row else None def add_edge_by_ids( self, diff --git a/src/core/indexing/db_manager.py b/src/core/indexing/db_manager.py index a2dcb5c5..dce8c316 100644 --- a/src/core/indexing/db_manager.py +++ b/src/core/indexing/db_manager.py @@ -68,6 +68,13 @@ def __init__( # P1-13 audit: RLock — reset_connection() вызывает _warmup_cache() # из-под этого же лока (реентерабельность обязательна). self._write_lock = table_write_lock or threading.RLock() + # Run-level mutual exclusion, SEPARATE from _write_lock (2026-09-25). + # run() holds this for the whole reindex to prevent two concurrent + # indexers (auto-index + manual trigger both called index_project). + # It must NOT be the write lock: _bounded_link runs DB ops on another + # thread, so holding the RLock across the run deadlocked bulk_write. + # Non-reentrant on purpose: a second run blocks (no re-entrancy). + self._run_lock = threading.Lock() self._reindex_guard = threading.Event() # set = reindex идёт, search fast-fail # ─── Single-writer PID lock (Layer 3 defense) ─── @@ -633,6 +640,15 @@ def begin_write(self): ) from _lock_err return self._write_lock + def begin_run(self): + """Context manager: mutual exclusion between whole index runs. + + run() wraps its entire body in this. It is NOT the write lock, so DB + operations executed on other threads (bulk_write via _bounded_link) can + acquire _write_lock without deadlocking (2026-09-25). + """ + return self._run_lock + # ══════════════════════════════════════════════════════════ # Migration helpers (from IndexerTableMixin) # ══════════════════════════════════════════════════════════ diff --git a/src/core/indexing/db_writer.py b/src/core/indexing/db_writer.py index 4bc16871..dce393a6 100644 --- a/src/core/indexing/db_writer.py +++ b/src/core/indexing/db_writer.py @@ -38,7 +38,10 @@ def write_records( parser=None, ) -> list: """Собирает data_records и пишет в LanceDB. Возвращает records.""" - rel_path_str = parsed["rel_path"] + # POSIX path is the single canonical form (see indexer._parse_file_only). + from src.core.relpath import normalize_rel_path + + rel_path_str = normalize_rel_path(parsed["rel_path"]) current_hash = parsed["current_hash"] escaped_path = parsed.get("escaped_path", rel_path_str) existing_hash = parsed.get("existing_hash") @@ -234,7 +237,9 @@ def prepare_records( Used by bulk_write() to collect all records before a single batch insert. """ - rel_path_str = parsed["rel_path"] + from src.core.relpath import normalize_rel_path + + rel_path_str = normalize_rel_path(parsed["rel_path"]) current_hash = parsed["current_hash"] escaped_path = parsed.get("escaped_path", rel_path_str) existing_hash = parsed.get("existing_hash") diff --git a/src/core/indexing/index_project_runner.py b/src/core/indexing/index_project_runner.py index d74e2900..a5a321b8 100644 --- a/src/core/indexing/index_project_runner.py +++ b/src/core/indexing/index_project_runner.py @@ -33,6 +33,12 @@ logger = logging.getLogger("mscodebase_server.index_project") +# Hard bound for non-cancellable native links (LanceDB / llama.cpp) in the +# indexing chain (2026-09-25 chain map). A stalled native call must not freeze +# the phase forever. Env-overridable. +_LINK_TIMEOUT_SEC = float(os.environ.get("MSCODEBASE_LINK_TIMEOUT_SEC", "300")) + + class IndexProjectRunner: """Оркестрирует полную индексацию проекта. @@ -139,6 +145,32 @@ def _safe_recreate_table(self): except Exception as e: logger.error(f"Table recreate failed: {e}") + def _bounded_link(self, fn, label: str, *, fatal: bool = False, default=None): + """Bound a NON-CANCELLABLE native link (LanceDB / llama.cpp). + + Non-fatal links are skipped on timeout (index still usable); fatal links + (embed / DB write) raise a clear, resume-safe error — the incremental + checkpoint means a retry resumes instead of losing work (2026-09-25). + + Note (2026-09-25): run() now excludes concurrent runs via begin_run() + (a lock SEPARATE from the write lock), so `fn` may acquire the write + lock (bulk_write/prune) on its own thread without deadlocking the + caller. Do NOT wrap this in _suspend_write_lock again. + """ + from src.core.run_bounded import run_bounded + + sentinel = object() + result = run_bounded(fn, _LINK_TIMEOUT_SEC, label=label, default=sentinel) + if result is sentinel: + if fatal: + raise RuntimeError( + f"{label} exceeded {_LINK_TIMEOUT_SEC:.0f}s — native stall; " + f"job aborted (resume-safe: re-run resumes from checkpoints)" + ) + logger.warning(f"{label} exceeded {_LINK_TIMEOUT_SEC:.0f}s — skipped (non-fatal)") + return default + return result + def _verify_and_repair_table_integrity(self) -> None: """Гарантирует целостность таблицы ДО индексации (resume-safe). @@ -229,7 +261,7 @@ def _suspend_write_lock(self): Safe because the reindex guard (``is_reindexing()``) — not this lock — is what blocks concurrent search during reindex. """ - _dbm = self.db_manager + _dbm = getattr(self, "db_manager", None) if _dbm is None: yield return @@ -272,10 +304,24 @@ def run( return 0 BATCH_SIZE = 32 # benchmarked: batch=32 = 100ch/s sustained, 50/50 ok (2026-07-26) - - # Write operations сериализуются через db_manager.begin_write() - # PID-lock уже захвачен в db_manager.__init__() - with (self.db_manager.begin_write() if self.db_manager else threading_lock_context()): + # Per-file hard bound: a hung tree-sitter parse must not freeze the phase + # (timeout-class audit #3, 2026-09-25). + _PARSE_TIMEOUT_SEC = float(os.environ.get("MSCODEBASE_PARSE_TIMEOUT_SEC", "60")) + + # Runs mutually exclude each other via begin_run() (NOT the write lock: + # run() must not hold the write RLock across the whole run, or the DB + # ops _bounded_link executes on another thread deadlock — 2026-09-25). + # Fall back to begin_write() for partial/legacy db_manager fakes. + if self.db_manager is None: + _run_ctx = threading_lock_context() + else: + _begin_run = getattr(self.db_manager, "begin_run", None) + if _begin_run is None: + _begin_run = getattr( + self.db_manager, "begin_write", threading_lock_context + ) + _run_ctx = _begin_run() + with _run_ctx: logger.info("🔑 Write lock acquired for indexing") # Сканирование файлов @@ -304,7 +350,9 @@ def run( # поэтому целостность таблицы должна быть гарантирована до первого # bulk_write. Один раз за прогон (на пустой таблице почти бесплатно: # count_rows=0 + нет фрагментов). - self._verify_and_repair_table_integrity() + self._bounded_link( + self._verify_and_repair_table_integrity, "verify_integrity" + ) # P0-FIX (регрессия ac6e5ba0e P1-3): загружаем известные хэши ОДИН раз # в главном потоке (RLock reentrant — безопасно под begin_write) и @@ -315,9 +363,17 @@ def run( known_hashes: Dict[str, str] = {} if self.table is not None: try: - _kh_df = self.table.to_lance().to_pandas(columns=["file_path", "file_hash"]) - if not _kh_df.empty: - known_hashes = dict(zip(_kh_df["file_path"], _kh_df["file_hash"])) + _kh_df = self._bounded_link( + lambda: self.table.to_lance().to_pandas( + columns=["file_path", "file_hash"] + ), + "known_hashes_load", default=None, + ) + if _kh_df is not None and not _kh_df.empty: + from src.core.relpath import normalize_rel_path + + _kh_paths = _kh_df["file_path"].map(normalize_rel_path) + known_hashes = dict(zip(_kh_paths, _kh_df["file_hash"])) logger.debug(f"known_hashes bulk load: {len(known_hashes)} files") except Exception as _kh_err: logger.warning(f"known_hashes bulk load failed, parsing all files: {_kh_err}") @@ -341,11 +397,23 @@ def _notify_progress(done: int, total: int, phase: str, current: str, #Phase 1: Parallel Parse def _parse_worker(args): _idx, _root, _fname, _full_path = args - _rel_path = str(_full_path.relative_to(project_path)) + from src.core.relpath import normalize_rel_path + + # POSIX — otherwise `current_files_on_disk` ("\") would not match + # the POSIX paths in the DB and prune would delete the whole index. + _rel_path = normalize_rel_path(_full_path.relative_to(project_path)) current_files_on_disk.add(_rel_path) try: - parsed = self._parse_file_only( - _full_path, _rel_path, source="filesystem", known_hashes=known_hashes + from src.core.run_bounded import run_bounded + + parsed = run_bounded( + lambda: self._parse_file_only( + _full_path, _rel_path, + source="filesystem", known_hashes=known_hashes, + ), + timeout=_PARSE_TIMEOUT_SEC, + label=f"parse:{_fname}", + default=None, ) if parsed is not None: return {"parsed": parsed, "name": _fname, "rel": _rel_path} @@ -384,9 +452,12 @@ def _parse_worker(args): if parsed_count == 0: logger.info("No changes — index is current") if prune_deleted_files: - self._safe_prune(prune_deleted_files, current_files_on_disk) + self._bounded_link( + lambda: self._safe_prune(prune_deleted_files, current_files_on_disk), + "prune", default=0, + ) if self.searcher: - self.searcher.reindex() + self._bounded_link(self.searcher.reindex, "bm25_reindex") if progress_callback: progress_callback("", total_files, total_files, "complete") return 0 @@ -429,7 +500,13 @@ def _flush_pending(): if not _pending_prepared: return _t_write = time.time() - _written = self._db_writer.bulk_write(_pending_prepared) if self._db_writer else 0 + _written = ( + self._bounded_link( + lambda: self._db_writer.bulk_write(_pending_prepared), + "bulk_write", fatal=True, + ) + if self._db_writer else 0 + ) _write_elapsed = time.time() - _t_write _indexed_count += len(_pending_map) for _fp_idx, _rec_count in _pending_map: @@ -466,7 +543,10 @@ def _flush_pending(): t0 = time.time() try: - embeddings = self.embedder.embed_batch(batch_texts) + embeddings = self._bounded_link( + lambda: self.embedder.embed_batch(batch_texts), + "embed_batch", fatal=True, + ) except Exception as embed_err: logger.error(f"Embedder error: {embed_err}. Aborted.") raise RuntimeError(f"Embedder unavailable: {embed_err}. Aborted.") from embed_err @@ -587,13 +667,16 @@ def _flush_pending(): # Prune (с self-healing от Not Found) pruned = 0 if prune_deleted_files: - pruned = self._safe_prune(prune_deleted_files, current_files_on_disk) + pruned = self._bounded_link( + lambda: self._safe_prune(prune_deleted_files, current_files_on_disk), + "prune", default=0, + ) # BM25 reindex if indexed_count > 0 and self.searcher: if progress_callback: progress_callback("", total_files, total_files, "rebuilding_bm25") - self.searcher.reindex() + self._bounded_link(self.searcher.reindex, "bm25_reindex") # P2-5/LOGIC-5: сбрасываем кэш поиска (TTL 30с слишком долго # для свежих данных — после reindex результаты stale до 30с). @@ -618,10 +701,10 @@ def _flush_pending(): progress_callback("", total_files, total_files, "complete") if self.summarizer: - self.summarizer.save_cache() + self._bounded_link(self.summarizer.save_cache, "summarizer_save") if save_symbol_index: - save_symbol_index() + self._bounded_link(save_symbol_index, "save_symbol_index") logger.info( f"Indexing complete: {indexed_count} new/changed, " @@ -662,31 +745,35 @@ def _safe_ivf_index(self, timeout: float = 300.0) -> None: with self._suspend_write_lock(): self._run_ivf_index_body(timeout=timeout) + def _call_with_timeout(self, fn, timeout: float, label: str) -> bool: + """Bound a NON-CANCELLABLE call by abandoning a daemon worker. + + A Python thread cannot be cancelled; ``Future.result(timeout=)`` only + stops waiting, and ``ThreadPoolExecutor.shutdown(wait=True)`` would then + JOIN the still-running worker (the IVF finalize hang, 2026-09-25: job + stuck at 95% forever). The only way a timeout can bound a hung native + LanceDB call is to NOT join it: run it on a daemon thread and return on + timeout. ``daemon=True`` keeps process exit clean. + + Returns True if ``fn`` finished, False if it was abandoned. + """ + from src.core.run_bounded import run_bounded + + sentinel = object() + result = run_bounded(fn, timeout, label=f"ivf-{label}", default=sentinel) + return result is not sentinel def _run_ivf_index_body(self, timeout: float = 300.0) -> None: # Phase 1: optimize def _safe_optimize(): for attempt in range(2): try: - _opt_ex = ThreadPoolExecutor(max_workers=1) - try: - _opt_ex.submit(self.table.optimize).result(timeout=timeout) - except Exception as _opt_to: - logger.warning( - f"Table optimize exceeded {timeout:.0f}s timeout " - f"(continuing without blocking the job): {_opt_to}" - ) - # Не ждём вечно завершения optimize в фоне — job не должен висеть. - try: - _opt_ex.shutdown(wait=False) - except Exception: - pass - return True - finally: - try: - _opt_ex.shutdown(wait=True) - except Exception: - pass + if not self._call_with_timeout( + self.table.optimize, timeout, "optimize" + ): + # Abandoned (hung native call). The table state is + # uncertain — do NOT build create_index on top of it. + return False return True except Exception as e: if attempt == 0 and self._reset_table_if_not_found(e, "optimize", attempt): @@ -727,24 +814,11 @@ def _create_index_once(self=self) -> None: logger.info("IVF_FLAT index created (legacy API)") def _safe_create_index(): - from concurrent.futures import TimeoutError as _FuturesTimeout - for attempt in range(2): try: - _ci_ex = ThreadPoolExecutor(max_workers=1) - try: - _ci_ex.submit(_create_index_once).result(timeout=timeout) - except _FuturesTimeout: - logger.warning( - f"create_index exceeded {timeout:.0f}s timeout — " - f"proceeding WITHOUT vector index (non-critical)" - ) - return - finally: - try: - _ci_ex.shutdown(wait=False) - except Exception: - pass + self._call_with_timeout( + _create_index_once, timeout, "create_index" + ) return except Exception as e: if attempt == 0 and self._reset_table_if_not_found(e, "create_index", attempt): diff --git a/src/core/indexing/indexer.py b/src/core/indexing/indexer.py index 9ab0e0f1..8b35042b 100644 --- a/src/core/indexing/indexer.py +++ b/src/core/indexing/indexer.py @@ -2,6 +2,7 @@ MSCodebase Intelligence — Продакшен инкрементальный индекс на LanceDB с авто-очисткой (Pruning) """ +import contextlib import hashlib import logging import time @@ -350,6 +351,12 @@ def _parse_file_only( Returns: Dict с данными чанков или None если файл не изменился. """ + # Normalise to POSIX. Full reindex used to write "\" while hot-reload + # wrote "/", so the SAME file produced TWO rows (known_hashes miss -> + # re-add on every incremental pass -> ~2x index bloat, 2026-09-25). + from src.core.relpath import normalize_rel_path + + rel_path_str = normalize_rel_path(rel_path_str) try: # 1. Получаем existing_hash (из bulk-кэша или через self.table) existing_hash = None @@ -422,39 +429,46 @@ def _parse_file_only( # 4. Обновление SymbolIndex if self.parser is not None: try: - # Очистка PropertyGraph - if hasattr(self._symbol_index, "graph"): - pg = self._symbol_index.graph - if pg: + # Вся graph-запись файла (remove + add_*) — ОДНА транзакция + # (E18, 2026-09-25): per-row named-mutex + BEGIN/commit давал + # 142 ent/s; батч — ~67k. PropertyGraph.batch() открывает tx + # один раз, add_node/add_edge/delete_node её переиспользуют. + _pg = getattr(self._symbol_index, "graph", None) + _sym_ctx = ( + _pg.batch() if _pg is not None else contextlib.nullcontext() + ) + with _sym_ctx: + # Очистка PropertyGraph + if _pg is not None: self._symbol_index.remove_file(str(full_path)) - # AST-символы из кэша IndexParser (без повторного парсинга) - _ast_chunks, symbols = parsed.get("_ast_symbols", (None, None)) - if symbols: - with self._symbol_index_lock: - self._symbol_index.add_definitions(str(full_path), symbols) - calls = self.parser.extract_calls(full_path) - if calls: - with self._symbol_index_lock: - self._symbol_index.add_references(str(full_path), calls) - assignments = self.parser.extract_assignments(full_path) - if assignments: + # AST-символы из кэша IndexParser (без повторного парсинга) + _ast_chunks, symbols = parsed.get("_ast_symbols", (None, None)) + if symbols: with self._symbol_index_lock: - self._symbol_index.add_assignments(str(full_path), assignments) - imports = self.parser.extract_imports(full_path) - if imports: - with self._symbol_index_lock: - self._symbol_index.add_imports(str(full_path), imports) - # DECORATES/OVERRIDES (PropertyGraph-only, legacy SymbolIndex не имеет) - if hasattr(self._symbol_index, "add_decorators"): - decorators = self.parser.extract_decorators(full_path) - if decorators: + self._symbol_index.add_definitions(str(full_path), symbols) + calls = self.parser.extract_calls(full_path) + if calls: + with self._symbol_index_lock: + self._symbol_index.add_references(str(full_path), calls) + assignments = self.parser.extract_assignments(full_path) + if assignments: with self._symbol_index_lock: - self._symbol_index.add_decorators(str(full_path), decorators) - overrides = self.parser.extract_overrides(full_path) - if overrides: + self._symbol_index.add_assignments(str(full_path), assignments) + imports = self.parser.extract_imports(full_path) + if imports: with self._symbol_index_lock: - self._symbol_index.add_overrides(str(full_path), overrides) + self._symbol_index.add_imports(str(full_path), imports) + # DECORATES/OVERRIDES (PropertyGraph-only, legacy SymbolIndex не имеет) + if hasattr(self._symbol_index, "add_decorators"): + decorators = self.parser.extract_decorators(full_path) + if decorators: + with self._symbol_index_lock: + self._symbol_index.add_decorators(str(full_path), decorators) + overrides = self.parser.extract_overrides(full_path) + if overrides: + with self._symbol_index_lock: + self._symbol_index.add_overrides(str(full_path), overrides) except Exception as sym_err: logger.warning(f"SymbolIndex update failed for {rel_path_str}: {sym_err}") @@ -828,7 +842,9 @@ def index_file( if self.file_guard.should_skip_file(full_path): return False - rel_path_str = str(full_path.relative_to(project_path)) + from src.core.relpath import normalize_rel_path + + rel_path_str = normalize_rel_path(full_path.relative_to(project_path)) return self._index_single_file(full_path, rel_path_str, content=content) except Exception as e: logger.error(f"[index_file] Ошибка индексации {full_path}: {e}") diff --git a/src/core/intelligence/layer.py b/src/core/intelligence/layer.py index 9f8553f3..4d4bac02 100644 --- a/src/core/intelligence/layer.py +++ b/src/core/intelligence/layer.py @@ -32,6 +32,8 @@ logger = logging.getLogger("MSCodeBase.Intelligence") from dataclasses import asdict +from src.core import embedder_lease, reindex_ledger + # Импорты из декомпозированных модулей from src.core.intelligence.embed_progress import _embed_progress_from_log from src.core.intelligence.jobs import BackgroundJob, job_manager @@ -143,6 +145,10 @@ def __init__( self._reindex_task: Optional[asyncio.Task] = ( None # Prevent GC from collecting background reindex ) + # Watchdog timestamp: last observed progress (2026-09-25). If a reindex + # stays "running" without progress past the stall window, it is + # terminalised as failed and recorded to the ledger (zombie guard). + self._reindex_last_progress_ts: float = 0.0 self._reindex_lock = asyncio.Lock() # Единый threading.Lock для sync+async записи в IntelligenceStore # (asyncio.Lock не защищает от sync-доступа — см. P2-4 audit). @@ -734,6 +740,9 @@ async def _run_reindex_job(): job.status = "running" job.progress = 0.0 + self._reindex_last_progress_ts = time.time() + embedder_lease.touch("reindex_start") + reindex_ledger.record("start", job_id=job_id, project=str(self.project_path)) try: # Guard (AGENTS.md §5.13 / chunkhound SerialDatabaseExecutor): @@ -758,9 +767,15 @@ async def _run_reindex_job(): loop = asyncio.get_event_loop() # Создаём progress_callback, который маппит прогресс индексера (0..1) на шкалу job'а (0.1..1.0) + _phase_seen = [None] def _index_progress_callback( current_file, files_done, files_total, phase ): + self._reindex_last_progress_ts = time.time() + embedder_lease.touch("reindex") + if _phase_seen[0] != phase: + _phase_seen[0] = phase + reindex_ledger.record("phase", job_id=job_id, phase=phase) if files_total > 0: ratio = files_done / files_total if phase == "embedding": @@ -861,10 +876,13 @@ def _index_progress_callback( except Exception as e: logger.warning(f"Exception suppressed at layer.py: {e}") + reindex_ledger.record( + "error", job_id=job_id, **reindex_ledger.exception_fields(e) + ) job.status = "failed" job.error = str(e) job.ended_at = time.time() - logger.error(f"Ошибка фоновой индексации: {e}") + logger.error(f"Ошибка фоновой индексации: {e}", exc_info=True) # Consistency Engine (WS2): индексация упала — индекс недоверен. try: from src.core.consistency import get_consistency_tracker @@ -875,6 +893,23 @@ def _index_progress_callback( except Exception: # noqa: BLE001 pass finally: + # Гарантия терминального статуса (2026-09-25): если корутина + # вышла без completed/failed — фиксируем failed, чтобы клиент + # не видел вечный "running" (zombie job; py-spy 2026-09-25). + if getattr(job, "status", None) in ("pending", "running"): + job.status = "failed" + job.error = job.error or ( + "reindex coroutine exited without terminal status" + ) + job.ended_at = time.time() + reindex_ledger.record( + "zombie", job_id=job_id, progress=round(job.progress, 3), + reason=job.error, + ) + reindex_ledger.record( + "end", job_id=job_id, status=job.status, + elapsed_s=round(time.time() - job.started_at, 2), + ) # Снимаем guard в любом случае (успех/ошибка/таймаут), # чтобы search снова заработал после завершения reindex. _dbm = getattr(self.indexer, "db_manager", None) @@ -1969,6 +2004,49 @@ async def intel_get_telemetry(self, days: int = 7) -> dict: return result + _REINDEX_STALL_SEC = float(os.environ.get("MSCODEBASE_REINDEX_STALL_SEC", "900")) + + def _watchdog_reindex(self) -> None: + """Terminalise a stalled/zombie reindex and record it (never raises). + + A suspended coroutine never reaches its `finally`, so the done-callback + cannot fire. This runs on status polls: if the job is still "running" + but the driving task is done/absent, or there has been no progress past + the stall window, mark it failed and write a ledger row. + """ + try: + jid = getattr(self, "_reindex_job_id", None) + if not jid: + return + job = job_manager.get_job(jid) + if job is None or job.status not in ("pending", "running"): + return + task = getattr(self, "_reindex_task", None) + task_done = task is None or task.done() + _last_progress = getattr(self, "_reindex_last_progress_ts", 0.0) or 0.0 + stalled = ( + _last_progress > 0 + and (time.time() - _last_progress) + > self._REINDEX_STALL_SEC + ) + if task_done or stalled: + reason = ( + "reindex task ended without terminal status" + if task_done + else f"no progress for >{int(self._REINDEX_STALL_SEC)}s" + ) + job.status = "failed" + job.error = job.error or reason + job.ended_at = time.time() + self._reindex_job_id = None + self._reindex_task = None + reindex_ledger.record( + "zombie", job_id=jid, reason=reason, + progress=round(job.progress, 3), task_done=task_done, + ) + except Exception: # noqa: BLE001 — watchdog must never break a status poll + pass + def _enrich_job_response(self, job: BackgroundJob) -> Dict[str, Any]: """Обогащает ответ job'а служебными полями: poll_interval_seconds, progress_label, estimated_seconds. @@ -1977,6 +2055,11 @@ def _enrich_job_response(self, job: BackgroundJob) -> Dict[str, Any]: progress_label — человекочитаемый статус для UI. estimated_seconds — примерное оставшееся время для running-задач. """ + # Zombie/stall guard, tolerant of lightweight stubs (tests pass a + # SimpleNamespace without this method). + _watchdog = getattr(self, "_watchdog_reindex", None) + if callable(_watchdog): + _watchdog() base = asdict(job) base["progress"] = round(job.progress, 2) diff --git a/src/core/reindex_ledger.py b/src/core/reindex_ledger.py new file mode 100644 index 00000000..b6350e4b --- /dev/null +++ b/src/core/reindex_ledger.py @@ -0,0 +1,71 @@ +"""Durable reindex failure ledger — append-only JSONL, never raises. + +Why (2026-09-25): a full-reindex job was found as a *zombie* — status +"running", but no worker thread and no progress (py-spy: 7 threads, both +executor workers idle). Nothing was recorded, so the cause had to be guessed. +`layer.py` even logged `Exception suppressed at layer.py: ...` without a stack. +This module is the fix: every reindex phase/failure is written to a durable +record so the next incident is *diagnosed*, not guessed. + +Design constraints: +- MUST NOT raise, ever (a recording channel cannot break indexing). +- Append-only, one JSON object per line (crash-safe, greppable). +- Path via artifact_paths (single artifact convention, §WISDOM). +""" +from __future__ import annotations + +import json +import logging +import os +import time +import traceback +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger("MSCodeBase.ReindexLedger") + +__all__ = ["record", "ledger_path"] + + +def ledger_path() -> Optional[Path]: + """Resolve the ledger file. None if the artifact layer is unavailable.""" + try: + from src.core.artifact_paths import get_logs_dir + + return Path(get_logs_dir()) / "reindex_ledger.jsonl" + except Exception: # noqa: BLE001 — recording must not depend on this + return None + + +def record(event: str, *, job_id: str = "", **fields: Any) -> None: + """Append one ledger line. Swallows every error by design. + + Args: + event: short machine tag, e.g. "start" | "phase" | "error" | "end" | "zombie". + job_id: reindex job id. + **fields: arbitrary JSON-serialisable context (phase, progress, error, tb...). + """ + try: + path = ledger_path() + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + rec = { + "ts": time.time(), + "iso": time.strftime("%Y-%m-%d %H:%M:%S"), + "pid": os.getpid(), + "event": event, + "job_id": job_id, + } + for k, v in fields.items(): + rec[k] = v + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n") + except Exception: # noqa: BLE001 — never propagate + pass + + +def exception_fields(exc: BaseException) -> dict: + """Standard error+short-traceback fields for a ledger record.""" + tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + return {"error": f"{type(exc).__name__}: {exc}", "traceback": tb[-4000:]} diff --git a/src/core/relpath.py b/src/core/relpath.py new file mode 100644 index 00000000..a85ac3b2 --- /dev/null +++ b/src/core/relpath.py @@ -0,0 +1,15 @@ +"""Canonical relative-path form for the index. + +The index stores relative paths; they MUST be one form (POSIX, ``/``). Full +reindex used to emit Windows ``\\`` while hot-reload emitted ``/``, so the same +file became two rows and every incremental pass re-added the alternate form +(~2x bloat, 2026-09-25). Every write/compare path normalises through here. +""" +from __future__ import annotations + +__all__ = ["normalize_rel_path"] + + +def normalize_rel_path(path) -> str: + """Return the canonical POSIX relative path (backslashes -> slashes).""" + return str(path).replace("\\", "/") diff --git a/src/core/run_bounded.py b/src/core/run_bounded.py new file mode 100644 index 00000000..101a3345 --- /dev/null +++ b/src/core/run_bounded.py @@ -0,0 +1,69 @@ +"""Bound a NON-CANCELLABLE call by abandoning a daemon worker. + +A Python thread cannot be cancelled. ``Future.result(timeout=)`` only stops +waiting, and any explicit join — including a ``with ThreadPoolExecutor`` block's +``__exit__`` (``shutdown(wait=True)``) — then blocks on the hung worker. The only +way a timeout can bound a hung native/sync call is to run it on a daemon thread +and NOT join it (root cause of the 2026-09-25 IVF finalize hang; class audit see +``experiments/misc_probes/exp_timeout_class_audit.py``). + +Dependency-free by design: any layer may import it without creating a cycle. +""" +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +__all__ = ["run_bounded"] + + +def run_bounded( + fn: Callable[[], Any], + timeout: float, + *, + label: str = "call", + default: Any = None, +) -> Any: + """Run ``fn()`` with a hard time bound that can actually fire. + + Runs ``fn`` on a daemon worker thread and returns when either the worker + finishes or ``timeout`` elapses — WITHOUT ever joining the worker. If the + worker is still running at timeout it is ABANDONED (daemon=True keeps + process exit clean) and ``default`` is returned. + + Args: + fn: zero-arg callable (wrap args in a lambda). + timeout: seconds to wait before abandoning the worker. + label: name used in the abandonment warning / thread name. + default: value returned when the worker is abandoned. + + Returns: + ``fn()``'s result, or ``default`` if abandoned. A worker exception is + re-raised in the caller. ``SystemExit``/``KeyboardInterrupt`` from the + worker are not propagated (kept to ``Exception``). + """ + done = threading.Event() + box: dict = {} + + def _work() -> None: + try: + box["result"] = fn() + except Exception as exc: # noqa: BLE001 — re-raised in the caller + box["error"] = exc + finally: + done.set() + + th = threading.Thread(target=_work, name=f"bounded-{label}", daemon=True) + th.start() + if not done.wait(timeout): + logger.warning( + f"{label} exceeded {timeout:.0f}s — ABANDONED (daemon worker; " + f"non-blocking, non-critical)" + ) + return default + if "error" in box: + raise box["error"] + return box["result"] diff --git a/src/core/search/agentic_search.py b/src/core/search/agentic_search.py index 32acfd2f..90666091 100644 --- a/src/core/search/agentic_search.py +++ b/src/core/search/agentic_search.py @@ -546,20 +546,24 @@ def agentic_code_search( loop = None if loop and loop.is_running(): - import concurrent.futures + # Bound without joining: a hung search must not freeze past timeout + # (timeout-class audit #2, 2026-09-25). + from src.core.run_bounded import run_bounded - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit( - asyncio.run, + return run_bounded( + lambda: asyncio.run( self.agentic_code_search_async( query, symbol_index, max_subqueries, limit_per_subquery, max_total_results, - ), - ) - return future.result(timeout=60) + ) + ), + timeout=60, + label="agentic_code_search", + default=([], {}), + ) else: return asyncio.run( self.agentic_code_search_async( diff --git a/src/core/search/engine.py b/src/core/search/engine.py index 37de7c61..e8d012b6 100644 --- a/src/core/search/engine.py +++ b/src/core/search/engine.py @@ -495,14 +495,21 @@ def hybrid_search( loop = None if loop and loop.is_running(): - # Уже внутри event loop — запускаем в отдельном потоке - future = _sync_executor.submit( - asyncio.run, - self.hybrid_search_async( - query, limit, use_rrf, expand, since, before, layer, intent_hint, tracer + # Bound without joining (timeout-class audit, 2026-09-25): a hung + # search must not leak a non-daemon pool worker. + from src.core.run_bounded import run_bounded + + return run_bounded( + lambda: asyncio.run( + self.hybrid_search_async( + query, limit, use_rrf, expand, since, before, layer, + intent_hint, tracer, + ) ), + timeout=30, + label="hybrid_search", + default=[], ) - return future.result(timeout=30) else: return asyncio.run( self.hybrid_search_async( diff --git a/src/mcp/tools/indexing_tools.py b/src/mcp/tools/indexing_tools.py index 0401d3b8..f01375bb 100644 --- a/src/mcp/tools/indexing_tools.py +++ b/src/mcp/tools/indexing_tools.py @@ -57,7 +57,7 @@ async def execute( # Получаем контент из LSP VFS или с диска content, source = await self._get_content(rel_path) - rel_path_str = str(rel_path.relative_to(project_root)) + rel_path_str = str(rel_path.relative_to(project_root)).replace("\\", "/") # Consistency Engine (WS2): источник изменён — индекс станет устаревшим # до завершения фоновой переиндексации. diff --git a/src/providers/reranker/llama_runner.py b/src/providers/reranker/llama_runner.py index 1488521c..dceb17c1 100644 --- a/src/providers/reranker/llama_runner.py +++ b/src/providers/reranker/llama_runner.py @@ -414,6 +414,15 @@ def _start_watchdog(self): + @staticmethod + def _embedder_unload_allowed() -> bool: + """True only if no process holds a fresh embedder lease (2026-09-25).""" + try: + from src.core import embedder_lease + return not embedder_lease.is_active() + except Exception: + return True + def _watchdog_loop(self): while not self._watchdog_stop.wait(30): @@ -434,18 +443,29 @@ def _watchdog_loop(self): if self._process is not None and self._last_embedder_use > 0: idle_e = time.time() - self._last_embedder_use if idle_e > self.EMBEDDER_IDLE_TIMEOUT: - logger.info(f"🧹 Embedder idle {idle_e:.0f}s > {self.EMBEDDER_IDLE_TIMEOUT}s — stopping (~1.5GB RAM)") - try: - self._process.terminate() - self._process.wait(timeout=5) - except Exception: + # Multi-window guard (2026-09-25): the embedder is shared on + # a fixed port by all workspaces. During a long reindex parse + # phase there are no embed calls, so it looks idle; unloading + # it lets another workspace's server grab the port. Skip the + # unload while any process holds a fresh indexing lease. + if not self._embedder_unload_allowed(): + logger.info( + f"⏳ Embedder idle {idle_e:.0f}s, но активен lease " + f"(индексация идёт) — не выгружаю (shared)" + ) + else: + logger.info(f"🧹 Embedder idle {idle_e:.0f}s > {self.EMBEDDER_IDLE_TIMEOUT}s — stopping (~1.5GB RAM)") try: - self._process.kill() + self._process.terminate() + self._process.wait(timeout=5) except Exception: - pass - self._process = None - self._last_embedder_use = 0.0 - logger.info("✅ llama-server (embedder) stopped, RAM freed") + try: + self._process.kill() + except Exception: + pass + self._process = None + self._last_embedder_use = 0.0 + logger.info("✅ llama-server (embedder) stopped, RAM freed") # Проверка RAM per-process diff --git a/tests/test_bounded_link_deadlock.py b/tests/test_bounded_link_deadlock.py new file mode 100644 index 00000000..6f267f20 --- /dev/null +++ b/tests/test_bounded_link_deadlock.py @@ -0,0 +1,69 @@ +"""Regression guard: run() excludes concurrent runs via a lock SEPARATE from +the write lock (2026-09-25). + +Historical deadlock: run() held the write RLock for the whole run while +`_bounded_link` executed `bulk_write` on a NEW thread which re-acquired the +same RLock -> permanent deadlock (py-spy: "bounded-bulk_write" idle at +db_writer.py:336; job stuck at 52%). + +Fix: run() uses `db_manager.begin_run()` (a separate, non-reentrant Lock); +`_bounded_link` no longer releases/touches the write lock. So a bounded DB op +on another thread acquires `_write_lock` freely. + +Run: PYTHONPATH=src python -m pytest tests/test_bounded_link_deadlock.py -q +""" +from __future__ import annotations + +import threading +import time +from pathlib import Path + +import pytest + +from src.core.indexing import index_project_runner as ipr + +_ROOT = Path(__file__).resolve().parents[1] + + +def test_bounded_link_runs_write_locking_fn_without_deadlock(monkeypatch): + monkeypatch.setattr(ipr, "_LINK_TIMEOUT_SEC", 1.0) + + run_lock = threading.Lock() + write_lock = threading.RLock() # what bulk_write actually acquires + + obj = object.__new__(ipr.IndexProjectRunner) + + def fn(): + with write_lock: # acquired on the bounded worker thread + return 42 + + t0 = time.perf_counter() + with run_lock: # run() holds ONLY the run lock, not the write lock + res = ipr.IndexProjectRunner._bounded_link(obj, fn, "bulk_write", fatal=True) + dt = time.perf_counter() - t0 + + assert res == 42 + assert dt < 0.9, f"took {dt:.2f}s — deadlock behaviour returned" + + +def test_bounded_link_does_not_touch_write_lock_suspender(): + # Structural contract: the old (broken) remedy must not come back. + src = (_ROOT / "src/core/indexing/index_project_runner.py").read_text( + encoding="utf-8" + ) + start = src.index("def _bounded_link") + end = src.index("\n def ", start + 1) + body = src[start:end] + assert "with self._suspend_write_lock" not in body + assert "begin_run(" in src + + +def test_suspend_write_lock_yields_when_db_manager_missing(): + # _safe_ivf_index still uses it; must be harmless on a bare object. + obj = object.__new__(ipr.IndexProjectRunner) + with obj._suspend_write_lock(): + pass + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/tests/test_embedder_lease.py b/tests/test_embedder_lease.py new file mode 100644 index 00000000..46c525d1 --- /dev/null +++ b/tests/test_embedder_lease.py @@ -0,0 +1,63 @@ +"""Guard: cross-process embedder lease prevents idle-unload during indexing. + +Context (2026-09-25): the llama.cpp embedder is shared on a fixed port by all +MSCodeBase workspaces. Its idle watchdog unloads it after 120s; a long reindex +parse phase issues no embed calls, so another workspace could grab the port. +The lease marks "embedder in use" so the watchdog skips the unload. + +Run: PYTHONPATH=src python -m pytest tests/test_embedder_lease.py -q +""" +from __future__ import annotations + +import pytest + +from src.core import embedder_lease +from src.providers.reranker import llama_runner + + +@pytest.fixture() +def lease_tmp(tmp_path, monkeypatch): + p = tmp_path / "embedder.lease" + monkeypatch.setattr(embedder_lease, "lease_path", lambda: p) + return p + + +def test_touch_makes_lease_active(lease_tmp): + assert embedder_lease.is_active() is False + embedder_lease.touch("reindex") + assert embedder_lease.is_active() is True + assert lease_tmp.exists() + + +def test_lease_expires(lease_tmp): + embedder_lease.touch("reindex") + assert embedder_lease.is_active(max_age_s=0.0) is False + + +def test_corrupt_lease_is_inactive_and_never_raises(lease_tmp): + lease_tmp.write_text("{not json", encoding="utf-8") + assert embedder_lease.is_active() is False + + +def test_watchdog_skips_unload_while_lease_active(lease_tmp): + embedder_lease.touch("reindex") + assert llama_runner.LlamaRunner._embedder_unload_allowed() is False + + +def test_watchdog_allows_unload_without_lease(lease_tmp, monkeypatch): + # Negative control: no lease (and it never existed) -> unload allowed. + monkeypatch.setattr( + embedder_lease, "is_active", lambda max_age_s=300.0: False + ) + assert llama_runner.LlamaRunner._embedder_unload_allowed() is True + + +def test_unload_allowed_when_lease_module_broken(monkeypatch): + def _boom(*_a, **_k): + raise RuntimeError("lease unavailable") + + import src.core.embedder_lease as el + + monkeypatch.setattr(el, "is_active", _boom) + # Must default to True (fail-open to RAM freeing) and never raise. + assert llama_runner.LlamaRunner._embedder_unload_allowed() is True diff --git a/tests/test_error_envelope.py b/tests/test_error_envelope.py new file mode 100644 index 00000000..ce7c74fc --- /dev/null +++ b/tests/test_error_envelope.py @@ -0,0 +1,54 @@ +"""Guard for the typed, actionable error envelope (2026-09-25).""" +import json + +import pytest + +from src.core.error_envelope import ( + ErrorCode, + error_json, + playbook, + tool_error, +) + + +def test_every_code_has_a_concrete_next_action(): + for code in ErrorCode: + hint = playbook(code) + assert isinstance(hint, str) and len(hint) > 10, f"{code} has no hint" + + +def test_tool_error_shape_and_recovery_hint(): + err = tool_error(ErrorCode.NOT_FOUND, "no such symbol") + d = err.to_dict() + assert d["ok"] is False + assert d["error"]["code"] == "NOT_FOUND" + assert d["error"]["message"] == "no such symbol" + assert "reindex" in d["error"]["next_action"].lower() + + +def test_error_json_is_valid_and_parsable(): + raw = error_json(ErrorCode.INDEX_NOT_READY, "0 chunks") + parsed = json.loads(raw) + assert parsed["ok"] is False + assert parsed["error"]["code"] == "INDEX_NOT_READY" + assert parsed["error"]["next_action"] + + +def test_detail_is_appended_to_hint(): + err = tool_error(ErrorCode.TIMEOUT, "exceeded 30s", detail="tool=search_code") + assert "tool=search_code" in err.next_action + + +def test_details_pass_through(): + err = tool_error(ErrorCode.VALIDATION, "bad arg", details={"field": "query"}) + assert err.details == {"field": "query"} + + +def test_next_action_override(): + err = tool_error(ErrorCode.INTERNAL, "boom", next_action="Call support.") + assert err.next_action == "Call support." + + +def test_unknown_code_raises(): + with pytest.raises(ValueError): + tool_error("NOT_A_REAL_CODE", "x") diff --git a/tests/test_graph_batch.py b/tests/test_graph_batch.py new file mode 100644 index 00000000..da59ba02 --- /dev/null +++ b/tests/test_graph_batch.py @@ -0,0 +1,115 @@ +"""Guard for PropertyGraph.batch() (E18, 2026-09-25). + +Per-row named-mutex + BEGIN/commit gave ~142 entities/s; batching all node/edge +writes of one file into a single transaction is ~67k/s (exp_graph_write_throughput). +These tests prove the batched path is CORRECT (same counts), ATOMIC (rollback on +error), REENTRANT, and leaves non-batch behaviour unchanged. + +Run: PYTHONPATH=src python -m pytest tests/test_graph_batch.py -q +""" +from __future__ import annotations + +import pytest + +from src.core.graph import PropertyGraph + +N_NODES = 40 +N_EDGES = 80 + + +def _seed(pg: PropertyGraph, batched: bool) -> None: + def _do(): + for i in range(N_NODES): + pg.add_node(name=f"n{i}", qualified_name=f"p.f{i}", label="Function") + for i in range(N_EDGES): + pg.add_edge(f"p.f{i % N_NODES}", f"p.f{(i + 1) % N_NODES}", type="CALLS") + + if batched: + with pg.batch(): + _do() + else: + _do() + + +def test_batch_counts_match_nonbatch(tmp_path): + pg1 = PropertyGraph(tmp_path / "a.db") + pg2 = PropertyGraph(tmp_path / "b.db") + _seed(pg1, batched=False) + _seed(pg2, batched=True) + + assert pg1.count_nodes() == pg2.count_nodes() == N_NODES + assert pg1.count_edges() == pg2.count_edges() + assert pg2.count_edges() > 0 + + +def test_batch_is_atomic_rolls_back_on_error(tmp_path): + pg = PropertyGraph(tmp_path / "c.db") + with pytest.raises(RuntimeError): + with pg.batch(): + pg.add_node(name="x", qualified_name="p.x", label="Function") + raise RuntimeError("boom") + assert pg.count_nodes() == 0, "partial batch must be rolled back" + + # Graph stays usable after a rolled-back batch. + pg.add_node(name="y", qualified_name="p.y", label="Function") + assert pg.count_nodes() == 1 + + +def test_nested_batch_reuses_one_transaction(tmp_path): + pg = PropertyGraph(tmp_path / "d.db") + with pg.batch(): + pg.add_node(name="a", qualified_name="p.a", label="Function") + with pg.batch(): # nested -> same tx + pg.add_node(name="b", qualified_name="p.b", label="Function") + pg.add_node(name="c", qualified_name="p.c", label="Function") + assert pg.count_nodes() == 3 + + +def test_batch_commits_and_persists(tmp_path): + db = tmp_path / "e.db" + pg = PropertyGraph(db) + with pg.batch(): + pg.add_node(name="a", qualified_name="p.a", label="Function") + pg.close() + + reopened = PropertyGraph(db) + assert reopened.count_nodes() == 1 + + +def test_concurrent_batches_do_not_share_a_transaction(tmp_path): + # Multi-threaded parse phase (4 _parse_worker threads) wrote into one + # shared batch and corrupted each other ("cannot start a transaction within + # a transaction"). A different thread must NOT join an open batch; it must + # block and get its own. Barrier forces overlap; without the owner check + # this raises OperationalError and/or miscounts. + import threading + + pg = PropertyGraph(tmp_path / "f.db") + barrier = threading.Barrier(2) + errors: list = [] + n = 60 + + def worker(tag: str): + try: + barrier.wait(timeout=10) + with pg.batch(): + for i in range(n): + pg.add_node( + name=f"{tag}{i}", + qualified_name=f"p.{tag}{i}", + label="Function", + ) + except Exception as e: # noqa: BLE001 — collected, asserted below + errors.append(e) + + ts = [ + threading.Thread(target=worker, args=("a",)), + threading.Thread(target=worker, args=("b",)), + ] + for t in ts: + t.start() + for t in ts: + t.join(30) + assert not [t for t in ts if t.is_alive()], "batch deadlocked across threads" + assert not errors, f"concurrent batches interfered: {errors!r}" + assert pg.count_nodes() == 2 * n diff --git a/tests/test_graph_lock_threadsafe.py b/tests/test_graph_lock_threadsafe.py new file mode 100644 index 00000000..fc73b1da --- /dev/null +++ b/tests/test_graph_lock_threadsafe.py @@ -0,0 +1,45 @@ +"""Guard: graph.db lock must serialise SAME-process threads, not fail them. + +Regression for 2026-09-25: reindex finalize raised +"Could not acquire cross-process lock for graph.db within 30000ms" because the +named mutex is thread-owned — a second thread of the same process timed out +while another held it. Fix: local RLock per db_path + named mutex. +""" +import tempfile +import threading +import time +from pathlib import Path + +from src.core.graph import _cross_process_lock + + +def test_second_thread_waits_instead_of_failing(): + db = Path(tempfile.mkdtemp(prefix="glock_")) / "graph.db" + order: list[str] = [] + errors: list[str] = [] + + def holder(): + with _cross_process_lock(db, timeout_ms=1000): + order.append("A-in") + time.sleep(3.0) # hold past B's mutex timeout + order.append("A-out") + + def waiter(): + time.sleep(0.3) # ensure A holds first + try: + with _cross_process_lock(db, timeout_ms=1000): + order.append("B-in") + except Exception as exc: # noqa: BLE001 + errors.append(type(exc).__name__) + + ta = threading.Thread(target=holder) + tb = threading.Thread(target=waiter) + ta.start() + tb.start() + ta.join(timeout=10) + tb.join(timeout=10) + + assert not errors, f"second thread failed: {errors}" + assert "B-in" in order, f"waiter never acquired: {order}" + # B must acquire only after A released (serialised, not concurrent). + assert order.index("A-out") < order.index("B-in"), order diff --git a/tests/test_index_resume_incremental.py b/tests/test_index_resume_incremental.py index 7535a825..cf46af4e 100644 --- a/tests/test_index_resume_incremental.py +++ b/tests/test_index_resume_incremental.py @@ -171,6 +171,8 @@ def _make_runner(tmp_path, table: _FakeTable, n_files=40, n_chunks=1, def fake_parse_file_only(full_path, rel_path_str, source="filesystem", known_hashes=None): # Resume-сценарий: если файл уже в БД (known_hashes) → skip (вернуть None), # как реальный IndexParser делает при совпадении хэша. + # Canonical POSIX rel path (mirrors indexer._parse_file_only, 2026-09-25). + rel_path_str = str(rel_path_str).replace("\\", "/") if known_hashes and known_hashes.get(rel_path_str) == f"hash_{rel_path_str}": return None return _make_parsed(rel_path_str, n_chunks) diff --git a/tests/test_index_runner_deadlock.py b/tests/test_index_runner_deadlock.py index 3acf1701..e77f940f 100644 --- a/tests/test_index_runner_deadlock.py +++ b/tests/test_index_runner_deadlock.py @@ -237,10 +237,12 @@ def test_run_with_existing_known_hashes_skips_unchanged(tmp_path): (project / "src").mkdir(parents=True) (project / "src" / "a.py").write_text("A", encoding="utf-8") (project / "src" / "b.py").write_text("B", encoding="utf-8") - # Windows: str(Path.relative_to) даёт backslash — ключи таблицы должны - # совпадать с тем, что реально передаётся воркерам. - rel_a = str((project / "src" / "a.py").relative_to(project)) - rel_b = str((project / "src" / "b.py").relative_to(project)) + # 2026-09-25: worker rel paths are normalised to POSIX by the relpath + # contract, so known_hashes keys/checks must use the normalised form too. + from src.core.relpath import normalize_rel_path + + rel_a = normalize_rel_path((project / "src" / "a.py").relative_to(project)) + rel_b = normalize_rel_path((project / "src" / "b.py").relative_to(project)) seen = {} diff --git a/tests/test_path_contract.py b/tests/test_path_contract.py new file mode 100644 index 00000000..6bbe6878 --- /dev/null +++ b/tests/test_path_contract.py @@ -0,0 +1,54 @@ +"""Contract guard: index-feeding paths MUST be canonical POSIX. + +Root cause 2026-09-25 (index ~2x bloat): full reindex built rel paths with "\\" +while hot-reload built them with "/", so the same file became two rows. This +static guard fails the build if any index-feeding module builds a stored path via +a raw `str(...relative_to(...))` without normalising (normalize_rel_path / +as_posix / replace). It is a guard that CAN fail — it would have caught the bug. +""" +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +# Modules whose relative paths flow into the index / dedup / prune / ids. +FEED_FILES = [ + "src/core/indexing/indexer.py", + "src/core/indexing/db_writer.py", + "src/core/indexing/index_project_runner.py", + "src/core/indexing/freshness.py", + "src/core/indexing/index_status.py", + "src/mcp/tools/indexing_tools.py", +] + +_RAW_RELATIVE = re.compile(r"str\([^)]*relative_to\(") +_NORMALISERS = ("normalize_rel_path", "as_posix", ".replace(") + + +def _violations() -> list[str]: + bad: list[str] = [] + for rel in FEED_FILES: + path = ROOT / rel + if not path.exists(): + continue + for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if _RAW_RELATIVE.search(line) and not any(k in line for k in _NORMALISERS): + bad.append(f"{rel}:{i}: {line.strip()}") + return bad + + +def test_no_raw_relative_to_in_index_feeders(): + bad = _violations() + assert not bad, ( + "Index-feeding path built without canonical POSIX normalisation:\n" + + "\n".join(bad) + + "\nUse src.core.relpath.normalize_rel_path." + ) + + +def test_guard_can_fail(): + """Negative control: detector FLAGS a raw str(relative_to) and NOT a normalised one.""" + bad = 'rel = str(p.relative_to(root))' + assert _RAW_RELATIVE.search(bad) and not any(k in bad for k in _NORMALISERS) + good = 'rel = normalize_rel_path(p.relative_to(root))' + assert not _RAW_RELATIVE.search(good) diff --git a/tests/test_reindex_finalizing_deadlock.py b/tests/test_reindex_finalizing_deadlock.py index 1f188f21..056110b4 100644 --- a/tests/test_reindex_finalizing_deadlock.py +++ b/tests/test_reindex_finalizing_deadlock.py @@ -124,3 +124,42 @@ def test_safe_ivf_index_create_index_timeout_does_not_hang(): dt = time.perf_counter() - t0 assert dt < 3.0, f"create_index hung the Finalizing phase for {dt:.1f}s" + + +class _SlowOptimizeTable: + """Fake table whose optimize() blocks longer than the timeout. + + The original 2026-08-28 guard only covered a hung create_index() and + MISSED this case: _safe_optimize ended in `finally: shutdown(wait=True)`, + which JOINED the hung worker, so the timeout could never fire (2026-09-25). + """ + + def count_rows(self): + return 5000 + + def optimize(self): + time.sleep(10) # simulate a hung native LanceDB optimize + + def list_indices(self): + return [] + + def create_index(self, *args, **kwargs): + raise AssertionError( + "create_index must NOT run after an ABANDONED optimize " + "(table state is uncertain)" + ) + + +def test_safe_ivf_index_optimize_timeout_does_not_hang(): + """A hung optimize() must not block the job past the timeout. + + Regression for 2026-09-25 (job stuck at 'Finalizing 95%' forever).""" + dbm = _FakeDBM() + table = _SlowOptimizeTable() + runner = _make_runner(table, dbm) + + t0 = time.perf_counter() + runner._safe_ivf_index(timeout=1) # optimize sleeps 10s + dt = time.perf_counter() - t0 + + assert dt < 3.0, f"optimize hung the Finalizing phase for {dt:.1f}s" diff --git a/tests/test_reindex_ledger.py b/tests/test_reindex_ledger.py new file mode 100644 index 00000000..84db4a98 --- /dev/null +++ b/tests/test_reindex_ledger.py @@ -0,0 +1,100 @@ +"""Guard for the reindex failure ledger + zombie watchdog (2026-09-25). + +Incident: full-reindex job e4977ded was found as a *zombie* — job.status +"running", but py-spy showed no worker thread and no progress; the driving +coroutine never reached its `finally`, so nothing was recorded and the cause +had to be guessed. These tests prove the fix actually fails when the guard is +absent (negative control) and stays quiet for a healthy job. + +Run: PYTHONPATH=src python -m pytest tests/test_reindex_ledger.py -q +""" +from __future__ import annotations + +import pytest + +from src.core import reindex_ledger +from src.core.intelligence import layer as layer_mod +from src.core.intelligence.jobs import job_manager + + +@pytest.fixture() +def ledger_tmp(tmp_path, monkeypatch): + p = tmp_path / "reindex_ledger.jsonl" + + monkeypatch.setattr(reindex_ledger, "ledger_path", lambda: p) + return p + + +def _fake_layer(*, progress_age_s: float, task_done): + """Minimal stand-in exposing only what _watchdog_reindex touches.""" + class _Task: + def __init__(self, done): + self._done = done + + def done(self): + return self._done + + obj = object.__new__(layer_mod.ProjectIntelligenceLayer) + obj._reindex_job_id = job_manager.create_job("full_reindex") + job = job_manager.get_job(obj._reindex_job_id) + job.status = "running" + job.progress = 0.52 + obj._reindex_task = None if task_done is None else _Task(task_done) + obj._reindex_last_progress_ts = __import__("time").time() - progress_age_s + return obj, job + + +def test_record_writes_jsonl(ledger_tmp): + reindex_ledger.record("start", job_id="abc", project="X") + reindex_ledger.record("error", job_id="abc", error="Boom: nope") + lines = ledger_tmp.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 2 + assert '"event": "start"' in lines[0] + assert "Boom: nope" in lines[1] + + +def test_record_never_raises_on_bad_path(monkeypatch): + def _boom(): + raise RuntimeError("no path") + + monkeypatch.setattr(reindex_ledger, "ledger_path", _boom) + # Must not raise even though path resolution fails. + reindex_ledger.record("start", job_id="x") + + +def test_exception_fields_capture_traceback(): + try: + raise ValueError("deep failure") + except ValueError as e: + fields = reindex_ledger.exception_fields(e) + assert "ValueError: deep failure" in fields["error"] + assert "Traceback" in fields["traceback"] + + +def test_watchdog_terminalises_zombie_task_done(ledger_tmp): + # NEGATIVE CONTROL: with the guard absent this stays "running" forever. + obj, job = _fake_layer(progress_age_s=1.0, task_done=True) + job_manager.jobs[job.job_id] = job + layer_mod.ProjectIntelligenceLayer._watchdog_reindex(obj) + assert job.status == "failed" + assert obj._reindex_job_id is None + assert "zombie" in ledger_tmp.read_text(encoding="utf-8") + + +def test_watchdog_terminalises_stall(ledger_tmp, monkeypatch): + monkeypatch.setattr( + layer_mod.ProjectIntelligenceLayer, "_REINDEX_STALL_SEC", 10.0 + ) + obj, job = _fake_layer(progress_age_s=999.0, task_done=False) + layer_mod.ProjectIntelligenceLayer._watchdog_reindex(obj) + assert job.status == "failed" + assert "no progress" in (job.error or "") + + +def test_watchdog_leaves_healthy_job_alone(ledger_tmp): + # Positive control: recent progress + task still running -> untouched. + obj, job = _fake_layer(progress_age_s=1.0, task_done=False) + layer_mod.ProjectIntelligenceLayer._watchdog_reindex(obj) + assert job.status == "running" + assert obj._reindex_job_id == job.job_id + assert not ledger_tmp.exists() or "zombie" not in ledger_tmp.read_text("utf-8") diff --git a/tests/test_reindex_link_bounds.py b/tests/test_reindex_link_bounds.py new file mode 100644 index 00000000..9a9ccf30 --- /dev/null +++ b/tests/test_reindex_link_bounds.py @@ -0,0 +1,48 @@ +"""Guard: every native link in the indexer chain is bounded (2026-09-25). + +Failure injection: a hanging link must NOT freeze the phase. Non-fatal links are +skipped; fatal links (embed / DB write) raise a resume-safe error. +""" +import time + +import pytest + +import src.core.indexing.index_project_runner as ipr + + +def _runner() -> ipr.IndexProjectRunner: + return object.__new__(ipr.IndexProjectRunner) # bypass __init__ + + +def test_bounded_link_returns_value(): + r = _runner() + assert r._bounded_link(lambda: 42, "ok") == 42 + + +def test_bounded_link_non_fatal_skips_within_timeout(monkeypatch): + monkeypatch.setattr(ipr, "_LINK_TIMEOUT_SEC", 1.0) + r = _runner() + t0 = time.perf_counter() + out = r._bounded_link(lambda: time.sleep(10), "prune", default=0) + dt = time.perf_counter() - t0 + assert out == 0 + assert dt < 3.0, f"non-fatal link hung for {dt:.1f}s" + + +def test_bounded_link_fatal_raises_within_timeout(monkeypatch): + monkeypatch.setattr(ipr, "_LINK_TIMEOUT_SEC", 1.0) + r = _runner() + t0 = time.perf_counter() + with pytest.raises(RuntimeError, match="native stall"): + r._bounded_link(lambda: time.sleep(10), "embed_batch", fatal=True) + assert time.perf_counter() - t0 < 3.0 + + +def test_bounded_link_propagates_normal_exception(): + r = _runner() + + def _boom(): + raise ValueError("real error") + + with pytest.raises(ValueError, match="real error"): + r._bounded_link(_boom, "x") diff --git a/tests/test_relpath.py b/tests/test_relpath.py new file mode 100644 index 00000000..d4c222f9 --- /dev/null +++ b/tests/test_relpath.py @@ -0,0 +1,27 @@ +"""Guard: relative paths are canonical POSIX (index bloat root cause 2026-09-25).""" +from pathlib import Path + +from src.core.relpath import normalize_rel_path + + +def test_backslashes_become_slashes(): + assert normalize_rel_path("src\\core\\indexer.py") == "src/core/indexer.py" + + +def test_posix_is_unchanged(): + assert normalize_rel_path("src/core/indexer.py") == "src/core/indexer.py" + + +def test_accepts_path_objects(): + assert normalize_rel_path(Path("a") / "b" / "c.py") in ("a/b/c.py", "a\\b\\c.py") + assert normalize_rel_path(Path("a") / "b" / "c.py").count("/") == 2 + + +def test_idempotent(): + once = normalize_rel_path("x\\y\\z.md") + assert normalize_rel_path(once) == once + + +def test_two_forms_collapse_to_one(): + # The exact bug: same file, two separators -> one canonical key. + assert normalize_rel_path("src\\core\\x.py") == normalize_rel_path("src/core/x.py") diff --git a/tests/test_run_bounded.py b/tests/test_run_bounded.py new file mode 100644 index 00000000..efcd019e --- /dev/null +++ b/tests/test_run_bounded.py @@ -0,0 +1,41 @@ +"""Guard: run_bounded must actually bound a hung (non-cancellable) call. + +Regression for the timeout class (2026-09-25): a timeout that ends in a join +cannot fire. run_bounded never joins the worker — it abandons a daemon thread. +""" +import time + +import pytest + +from src.core.run_bounded import run_bounded + + +def test_finishes_returns_value(): + assert run_bounded(lambda: 42, timeout=5) == 42 + + +def test_exception_is_reraised(): + def _boom(): + raise ValueError("inner") + + with pytest.raises(ValueError, match="inner"): + run_bounded(_boom, timeout=5) + + +def test_hung_call_is_abandoned_within_timeout(): + t0 = time.perf_counter() + out = run_bounded(lambda: time.sleep(10), timeout=1.0, default="ABANDONED") + dt = time.perf_counter() - t0 + assert out == "ABANDONED" + assert dt < 3.0, f"run_bounded did not bound the hung call ({dt:.1f}s)" + + +def test_hang_does_not_join_a_non_daemon_worker(): + """The whole class: caller returns even though the worker keeps running.""" + t0 = time.perf_counter() + run_bounded(lambda: time.sleep(30), timeout=1.0) + assert time.perf_counter() - t0 < 3.0 + + +def test_default_returned_on_abandon_is_type_appropriate(): + assert run_bounded(lambda: time.sleep(5), timeout=1.0, default=([], {})) == ([], {}) diff --git a/tests/test_run_singleflight.py b/tests/test_run_singleflight.py new file mode 100644 index 00000000..67af80cb --- /dev/null +++ b/tests/test_run_singleflight.py @@ -0,0 +1,64 @@ +"""Guard: run-level single-flight is a lock SEPARATE from the write lock. + +Two indexers must never run concurrently (auto-index + manual trigger). Before +2026-09-25 the only mutex was the write RLock held for the whole run; releasing +it (to fix the bulk_write deadlock) let two runs execute at once. The fix splits +the two concerns: begin_run() (run exclusion) vs begin_write() (DB ops). + +Run: PYTHONPATH=src python -m pytest tests/test_run_singleflight.py -q +""" +from __future__ import annotations + +import threading + +import pytest + + +class _FakeEmbedder: + def __init__(self, dim: int = 8): + self.embedding_dim = dim + + def is_ready(self) -> bool: + return True + + +@pytest.fixture() +def dbm(tmp_path): + from src.core.indexing.db_manager import LanceDBManager + + db_path = tmp_path / "db" + db_path.mkdir() + m = LanceDBManager( + db_path=db_path, + embedder=_FakeEmbedder(), + project_path=tmp_path, + embedding_dim=8, + ) + yield m + import contextlib + + with contextlib.suppress(Exception): # noqa: BLE001 — teardown best-effort + m._db_lock.release() + + +def test_begin_run_is_not_the_write_lock(dbm): + assert dbm.begin_run() is not dbm.begin_write() + + +def test_begin_run_serializes_and_write_lock_stays_free(dbm): + got = [] + with dbm.begin_run(): + t = threading.Thread( + target=lambda: (dbm.begin_run().acquire(), got.append(1)) + ) + t.start() + t.join(0.4) + assert got == [], "second run acquired the run lock concurrently" + + # The split is what prevents the deadlock: DB writes are NOT blocked by + # the run lock, so a bounded bulk_write on another thread can proceed. + assert dbm.begin_write().acquire(blocking=False) is True + dbm.begin_write().release() + + t.join(2) + assert got == [1]