diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 8d9c785d..e5e889da 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -38,6 +38,22 @@ **Guard:** новые «проверки» проектной памяти обязаны иметь точку вызова вне ручного чтения (idle/event/ttl) — иначе это снова lazy-by-hand. **verified_from_clean_state:** ⚠️ не прогонялся (изменения только .md, live-данные из реального сервера PID 10036) +--- +## [2026-09-09] — H1: фоновый VOR-проход (IdleScheduler) — память перепроверяется без вызова агента +**Status:** Fixed (6 новых тестов + 1674 полный pytest green; ветка chore/experiments-es1-es2-0909) +**Root Cause:** VOR вызывался ровно из 1 места (intel_get_project_memory, layer.py:1097); idle-задача `_check_index_health` — заглушка → пока агент не дёрнет memory, REFUTED/VERIFIED не копились (Exhibit #23 аудит 2026-09-09; KNOWN_ISSUES 2026-09-07, дедлайн 2026-09-15). +**Fix:** (1) `set_idle_vor_callback()` в task_queue.py + вызов в `_check_index_health` (hook-инъекция: task_queue не импортирует layer → нет cycle-import). (2) `run_background_verify(budget_ms=250)` в layer.py: locked()-guard (Red Team a1 — agent-путь приоритетнее), общий `_write_lock` + `get_verifier`-регистр → idle-VOR и agent-VOR сериализуются в `_persist_transitions` без второй lock/гонки; `_build_symbol_resolver` вынесен из `intel_get_project_memory` (DRY, эквивалентный рефакторинг). (3) Регистрация hook в `server_tools._register_intelligence_tools` после создания `intel_layer` (enable_idle_scheduler вызывается раньше — layer ещё нет). Red Team 3 атаки: lock contention (защищено общим lock+budget), блокировка idle-потока (budget_ms=250 + cooldown 120s), stale hook при перерегистрации (перезапись каждый старт + try/except). +**Guard:** новые точки проверки памяти обязаны использовать существующий lock/verifier-регистр (не создавать второй) — иначе гонка записей project_memory.json. +**verified_from_clean_state:** ⚠️ не проверено — коммит не запушен, clean-state script не прогонялся; полный pytest 1674 passed локально, ruff-ошибка Fix + +--- +## [2026-09-09] — H2: .h заголовки C включены в AST-индексацию (PARSE_EXTENSIONS + C-парсер) +**Status:** Fixed (commit 0301fa93; KNOWN_ISSUES 2026-09-09 19:35 закрыт) +**Root Cause:** ".h" был в INDEX_EXTENSIONS (вектор-чанкинг шёл), но НЕ в PARSE_EXTENSIONS → CodeParser.parse_file возвращал [], [] (parser.py:438). C-заголовки без AST: нет импортов (#include), вызовов, присваиваний, condition_path. E-S1 live-проба (2026-09-09): curl 65/300 файлов с #include дали 0 рёбер — преимущественно .h; dart-http .c 0/9. +**Fix:** (1) extensions.py PARSE_EXTENSIONS: ".c" → ".c", ".h". (2) parser.py: регистрация `self.parsers[".h"] = ` (tree_sitter_c, не cpp — .h = C). (3) Карты языка по аналогии с ".c": `_EXT_TO_ENV_LANG` {".h": "c"}, IMPORT_NODE_MAP (preproc_include), ASSIGNMENT_NODE_TYPES (init_declarator, assignment_expression), CONDITIONAL_NODE_TYPES (if/for/while/do/switch/case/conditional_expression). (4) +1 тест `test_h_header_preproc_include` (57 passed в файле; full gate-zero через pre-commit). Red Team: прототип-only .h → fallback-line-chunking (не ломается), пустой .h → 0, .hpp остаётся CPP (другая карта не тронута). +**Guard:** интервал "вектор индексируется, AST нет" (INDEX_EXTENSIONS \ PARSE_EXTENSIONS) — проверять BATCH-check'ом при добавлении языка; тест на каждый новый suffix в PARSE_EXTENSIONS. +**verified_from_clean_state:** ⚠️ не проверено — повтор E-S1 live-пробы на curl отложен (требует внешний клон); unit-проверка: CodeParser.parse_file(.h) real tree-sitter → chunks≥1, symbols=[helper] + --- ## [2026-09-07] — Cypher-движок: анонимные узлы/рёбра ломали MATCH; ActionReceipt не писался из write-пути **Status:** Fixed (оба блока закрыты, тесты зелёные) @@ -163,3 +179,23 @@ **Tests:** 13 новых (SQL/E2E/errors incl. decode-collision `'["not_a_list"]'`); файл 93 passed; полный 1663 passed / 6 skipped / 91 deselected (168.6s). ruff clean, verify_diary 15/0. Эксперименты Г1/Г2 (sqlite 3.50.4, Python 3.14.3) — FILTER и empty→[] подтверждены сырым прогоном, см. .agent_task_state.md. **Guard:** тест «collect() без алиаса → имя колонки = выражение», decode-collision guard (не-decode не-marked колонок), Red Team 5/5 (empty, null, unicode/quotes, DISTINCT, nested/*). **verified_from_clean_state:** ⚠️ не проверено — чистый clone требует сети (нет в сессии); локально полный pytest 1663 passed green. + +## [2026-09-09] — Аудит «Active MSCodeBase» (Exhibit #23: MCP tool available but never invoked) + +**Status:** Open — зафиксирован гэп (исследование + план, код НЕ вносился) +**Root Cause:** фундамент (VOR / DebounceBatch / ConsistencyTracker / IdleScheduler / PropagationEngine) существует, но компоненты изолированы: цепь «файл изменён → STALE → VOR → alert агента» не собрана ни в одном звене. VOR вызывается ровно из 1 места (layer.py:1097, intel_get_project_memory); 2 из 3 idle-задач — пустые заглушки; ConsistencyTracker.mark_stale("memory") never called; system_alerts/precondition contract отсутствуют; FS-event-watcher отсутствует (только heartbeat-Watchdog). +**Fix (план, не внесён):** H1 — подключить VOR в `_check_index_health` (idle-ticker, cooldown 120s уже есть; ~15 строк). Затем optional: mark_stale("memory") в notify_change; system_alerts в ответы MCP-тулов. НЕ добавлять watchdog lib сейчас (notify_change = тот же event). +**Red Team:** (1) lock contention idle-VOR vs agent-VOR — один `_write_lock`, обёрнут asyncio.to_thread, добавить locked()-check; (2) H3 TTL-гниение НЕ применимо к INCONCLUSIVE (42 узла зависнут «навечно») — нужен H1; (3) import cycle — локальный import внутри try/except; (4) alerts токены — низкий риск (одноразовые); (5) concurrent FS при VOR — защищено freshness gate (commit B). 5/5 атак с защитой. +**Guard:** правило §9: перед интеграцией по чужому плану — верифицировать КАЖДЫЙ API через get_symbol_info/search_code (чужой план дал 3 несуществующих API: self.context, vor.run(nodes=), get_active_nodes()). +**verified_from_clean_state:** ⚠️ не прогонялся (изменения только .md; факты из MCP, не из запуска) + +## [2026-09-10] — H1 idle-VOR + system_alerts (цепь «файл изменён → STALE → VOR → alert агента» собрана) + +**Status:** ✅ Fixed / **Root Cause (Exhibit #23, 2026-09-09):** компоненты цепи существовали по отдельности, но VOR вызывался ровно из 1 места (layer.py:intel_get_project_memory), mark_stale("memory") никогда не вызывался, system_alerts не было. +**Fix (два коммита в ветке chore/experiments-es1-es2-0909):** +- **H1:** `set_idle_vor_callback()` в task_queue.py + вызов из `_check_index_health` (idle-тик, cooldown 120s); `run_background_verify(budget_ms=250)` в layer.py с locked()-guard против agent-VOR (общий `_write_lock`, `get_verifier`-регистр); `_build_symbol_resolver` вынесен из `intel_get_project_memory` (DRY); регистрация hook в `server_tools`. +- **system_alerts:** `AlertStore` (src/core/intelligence/alert_store.py, JSON вне проекта в /projects//intelligence/, threading.Lock т.к. несколько event-loop'ов, дедуп по kind+payload, атомарный collect_and_clear limit=5). Источники: (a) stale — `mark_stale("memory")` в notify_change ТОЛЬКО при переходе →STALE (не спамим на каждый save; reason меняется и дедуп по payload не спасёт); (b) starved — idle VOR-проход и `intel_get_project_memory` для узлов видимы ≥2 циклов (MATCHED>0, DELIVERED=0). Доставка: `format_system_alerts` prepend в `intel_get_project_memory` (tools_reg) + секция в `intel_explain_project_state` (server_tools, try/except — алерты не роняют диагноз). +**Tests:** 11 в test_alert_store.py (push/collect/clear одноразовость/дедуп/limit/corrupt-json/гонка 2 threading-потоков 20 alerts/per-project изоляция/синглтон по resolved path/orphan) + 3 в test_ui_formatter_memory (empty/alerts render/payload limit 3). Полный pytest 1689 passed / 5 skipped / 91 deselected (180.8s); ruff clean после --fix; architecture_linter «Все инварианты соблюдены». +**Red Team:** (1) дубль-доставка при гонке двух MCP-тулов — collect_and_clear атомарный (первый забрал, второй — пусто); (2) спам на каждый notify_change — alert только при первом переходе →STALE; (3) токен-оверхед — limit=5, payload до 3 ключей; (4) коррапт JSON — graceful reset; (5) multi-window — per-project store. 5/5 с защитой. +**Guard:** дедуп в push + лимиты, single-threaded write под lock. .h-хедеры (H2) — отдельный коммит 0301fa93 (см. KNOWN_ISSUES «`.h` не парсился AST» → Fixed). +**verified_from_clean_state:** ⚠️ не проверено — чистый clone не гонялся (нет сети в сессии); локально полный pytest 1689 passed / 91 deselected (Windows, без e2e/shadow-маркеров — llama недоступен, slow/benchmark отсечены addopts). diff --git a/EXPERIMENTS_LOG.md b/EXPERIMENTS_LOG.md index 24b12044..c6a76370 100644 --- a/EXPERIMENTS_LOG.md +++ b/EXPERIMENTS_LOG.md @@ -1764,3 +1764,78 @@ static_predict напрямую (терминал): ELAPSED 0.53s, changed=3, af **Урок:** «таймаут, который не может прервать работающий sync-блок, — фарс». В async MCP-инструментах любой потенциально долгий subprocess/filesystem-блок обязан быть в `asyncio.to_thread` (§9 incorporate). Завышенный впрок таймаут (60-120s) лучше заниженного (10s): реальная длительность изменчива (10→29s в потоке). **Связь:** AGENT_DIARY#2026-09-05-1230, KNOWN_ISSUES#2026-09-05-stale-predict-timeout, files src/mcp/tools/doc_tools.py, src/mcp/tools/predict_tools.py. + +## [2026-09-09] - E-S1/E-S2: языковые карты импортов/вызовов/скелета на живых топ-репо (6 языков) + свежесть индекса + +**Контекст:** закрыть незавершённые шаги E-S1/E-S2 из дока 05 §3 п.5 и дока 08 §3: эмпирически проверить карты parser.py (фиксы B3+B4, PR #25) на реальном коде; заодно перепроверить синтетический дефект «вызовы PHP/Ruby/C/Dart = 0» и свежесть индекса после PR #31. Прогон выполнен AutoCoder (внешняя сессия) без изменений репозитория: shallow-клоны 6 репо (commons-lang, composer, curl, kotlinx-serialization, dart-http, rspec-core) в рабочей папке вне репо; venv python; CodeParser.extract_imports/extract_calls/extract_assignments; кап 300 файлов/язык; read_text utf-8 errors=replace. + +**Сырые результаты (import_edge_rate / call_edge_rate / assignments+condition_path):** +``` +java commons-lang files=300 imp=0.867 (1487) lies=0 call=0.710 (26184) asg=1983 cond=1197 +php composer files=300 imp=0.797 (1827) lies=13 call=0.813 (16142) asg=3473 cond=2058 +c curl (.c) files=300 imp=0.680 (1253) lies=65 call=0.250 (5791) asg=1685 cond=1332 +kotlin kotlinx-serialization files=300 imp=0.853 (1071) lies=18 call=0.663 (5005) asg=754 cond=220 +dart dart-lang/http files=300 imp=0.940 (1210) lies=2 call=0.080 (57) asg=549 cond=148 +ruby rspec-core files=233 imp=0.618 (557) lies=21 call=0.562 (3620) asg=731 cond=40 +lies = файлы с явными import/include/require в тексте, но 0 извлечённых рёбер ("карта врёт" по дока 05 §3 п.5) +``` + +**Вывод:** (1) E-S1 закрыт: карты импортов живые на всех 6 языках (61.8%-94% файлов дают рёбра). (2) Дефект «вызовы PHP/Ruby/C/Dart» со синтетической пробы СНЯТ: на реальном коде все 4 языка извлекают вызовы (0.08-0.81); синтетика вводила в заблуждение (мини-файлы без полного function-контекста -> current_function пуст). (3) E-S2 закрыт: скелет ASSIGNED_FROM+condition_path даёт тысячи строк вне Python. (4) Остаточный риск: ненулевые map_lies (curl 65/300 - преимущественно .h, php 13, kotlin 18, ruby 21, dart 2). (5) НОВАЯ НАХОДКА: ".h" отсутствует в SUPPORTED_EXTENSIONS/parsers (есть .hpp/.cxx/.cpp) - заголовки C-проектов выпадают из индекса (curl lies в осн. .h; dart-http .c 0/9). Кандидат в KNOWN_ISSUES. (6) Свежесть индекса: до реиндекса в PropertyGraph отсутствовали новые test_collect_* из PR #31; intel_trigger_reindex (Job 52f138be) поднял symbol index 11053->11081, но узлы графа для новых тестов так и не появились и chunks остались 9308 - lazy-реиндекс обновляет symbol index без пересборки графа (согласуется с KNOWN_ISSUES #13 Lazy-only). + +**Прав:** пробы выполнялись вне репозитория; файлы репо этим экспериментом не изменялись. Методологическая заметка: первая версия пробы AutoCoder падала TypeError str-in-bytes на каждом файле (баг пробы, не проекта) - после фикса все errs=0; полезное напоминание верифицировать нулевые результаты пробы на санинтарном файле перед выводами о картах. + +**Связи:** docs/research/universal-engine-study/05-grammar-node-kinds.md §3, docs/research/universal-engine-study/08-e-s1-polygon.md §3, KNOWN_ISSUES #13, аудиторский отчёт AutoCoder 2026-09-08/09 (delivery/mscodebase-readiness-audit-2026-09-08.html), полные JSON/логи пробы: .cluster/mscodebase-audit-0908/evidence/ (внешняя рабочая папка AutoCoder). + +## [2026-09-09] — «MCP tool available but never invoked» (Exhibit #23) + Active MSCodeBase план: аудит компонентов + +**Гипотеза (из внешнего чат-лога GLM-5.3 + собственный повтор):** агент НЕ вызывает доступный MCP-тул даже при явной инструкции в промпте («вызови MCP») — промпт = рекомендация (suggestion), не enforcement. Результат — не галлюцинация, а «silent skip»: агент выглядит работающим, но данные не свежие. Решение по аналогии с #21 (modification guard): precondition contract — «не отвечай, пока данные не загружены», fail-closed. Архитектурный сдвиг: Pull (агент вызывает) → Push (система подготавливает данные ДО агента). + +**Проверка (MCP-first, все через search_code/get_symbol_info/read_live_file, не grep):** +- `_check_index_health` (task_queue.py:373) — module-level функция (НЕ метод), тело = `logger.debug`. Заглушка. +- `_improve_summaries_batch` (task_queue.py:385) — та же пустота. 2 из 3 idle-задач пустые; 3-я (`_update_docs_if_stale`) реальная. +- `VerifyOnRead.run(memory, budget_ms=50)` (verify_on_read.py:679) — сигнатура принимает ПОЛНЫЙ dict памяти, НЕ пачку nodes. +- `store.get_active_nodes()` — НЕ СУЩЕСТВУЕТ (search_code → not found). Память грузится `load_memory()`, фильтрация ACTIVE внутри VOR. +- VOR вызывается ровно из 1 места: layer.py:1097 (`intel_get_project_memory`, verify_on_read=True). Lazy by design (ADR-0003). +- `ConsistencyTracker.mark_stale("memory")` — домен "memory" объявлен в DOMAINS, но mark_stale для него нигде не вызывается. STALE не связан с VOR. +- FS-watcher (watchdog.Observer/FileSystemEventHandler) — ОТСУТСТВУЕТ. Есть только heartbeat-Watchdog (индексатор) + `_start_zed_parent_watchdog` + llama idle-watchdog. FileSystemEventHandler — 0 results. +- `system_alerts`/`pending_stale`/`stale_alerts` — ОТСУТСТВУЕТ (search фразы → 0). +- `DebounceBatch` (rate_limiter.py:128) — ЕСТЬ, debounce 500ms, вызывается из `notify_change` (indexing_tools.py:69). +- `IdleScheduler` (task_queue.py:337-438) — ЕСТЬ, включается из `record_tool_call()`, cooldown 120s, CPU-guard `_cpu_available()`. Точка интеграции для фонового VOR. +- `PropagationEngine` (propagation_engine.py:59) — каскадная ретракция, но только при ручном `intel_retract`. + +**Ключевая находка:** фундамент ЕСТЬ (VOR / DebounceBatch / ConsistencyTracker / IdleScheduler / PropagationEngine), но компоненты ИЗОЛИРОВАНЫ — цепочка «файл изменён → STALE → VOR → alert агента» не существует ни в одном звене. Это и есть «23-й экземпляр», подтверждённый на собственном коде: VOR живёт, но не вызывается никем, кроме ручного read. + +**Отклонённый фрагмент чужого плана (Red Team):** чужой код-план (другой агент) использовал НЕсуществующие API: `self.context.verify_on_read` (нет self), `vor.run(nodes=...)` (нет такого параметра), `store.get_active_nodes()` (нет метода). Вердикт: план угадан по архитектуре, не по коду — брать концепт, не код. + +**Атаки Red Team на предложенную интеграцию (VOR в idle):** +1. Lock contention idle-VOR vs agent-VOR (один `_write_lock`) → ⚠️ средний: `asyncio.to_thread` в layer.py:1107, idle-VOR идёт в отдельном потоке TaskQueue → нет deadlock, но добавить `locked()`-check перед запуском из idle. +2. H3 TTL-гниение к INCONCLUSIVE-узлам НЕ применима (INCONCLUSIVE статус не меняется без якорей) → TTL гниение работает только VERIFIED→STALE; 42 узла без verified_at с 2026-08-11 останутся «навечно». Вывод: нужен H1 (idle-ticker), не H3. +3. Import cycle task_queue → verify_on_read → layer → проверить architecture_linter. Защита: локальный import внутри try/except как `_update_docs_if_stale`. +4. system_alerts оверхед токенов → низкий: alerts одноразовые (clear после delivery), VOR не частый (cooldown 120s). +5. Concurrent файловые изменения при VOR → защищено freshness gate (evaluate_freshness/resolve_head_dirty, commit B): fingerprint кэшируется по build_head, mismatch → INCONCLUSIVE, не ложный REFUTED. + +**Вердикт:** ПОДТВЕРЖДЕНО (аудит несуществующей «активной» цепи) + REFUTED (чужой код-план: API не существует). Рекомендация: (1) реализовать H1 — подключить VOR в `_check_index_health` (idle-ticker, ~15 строк); (2) не добавлять watchdog lib сейчас — `notify_change` уже ловит save; (3) system_alerts добавить после H1 (инъекция в ответы MCP-тулов). + +**Урок:** проверять чужой план против РЕАЛЬНЫХ сигнатур через get_symbol_info/search_code, а не против «как это выглядит по описанию». Три угаданных API ≠ одна рабочая интеграция. + +**Связи:** KNOWN_ISSUES 2026-09-07 «Lazy-only верификация» (открыт), docs/research/universal-engine-study/10-continuous-verification.md (план H1/H2/H3, pending), ADR-0003 (Verify-On-Read), commit B (head-freshness, cb88c961). + +## [2026-09-10] — system_alerts цепь «файл изменён → STALE → VOR → alert агента» + idle-VOR (H1) + +**Контекст:** закрыть Exhibit #23 (MCP tool available but never invoked) и KNOWN_ISSUES 2026-09-07 по приоритету «(3) system_alerts после H1». H1 (idle-VOR) реализован в AGENT_DIARY 2026-09-09; здесь — (А) одноразовые system_alerts и сквозная доставка в MCP-ответы. + +**Гипотеза:** цепь «файл изменён → STALE → VOR → alert агента» собирается существующими механизмами (DebounceBatch notify_change → ConsistencyTracker STALE → фоновый VOR → AlertStore → формат в ответе) без новых watchdog-библиотек и без токен-оверхеда. + +**Дизайн (probe):** `AlertStore` (src/core/intelligence/alert_store.py) — JSON вне проекта (`/projects//intelligence/system_alerts.json`), `threading.Lock` (не asyncio — несколько event-loop'ов), дедуп по kind+payload, атомарный `collect_and_clear(limit=5)`, `ALERT_KINDS=("memory_stale","memory_starved")`, неизвестный kind игнорируется. Генераторы: `memory_stale` — только при ПЕРВОМ переходе UNKNOWN/CONSISTENT→STALE в `notify_change` (не на каждый save — иначе спам, reason меняется на файл); `memory_starved` — из idle-VOR и `intel_get_project_memory` (виден ≥2 циклов, MATCHED>0/DELIVERED=0). Доставка: prepend `format_system_alerts` в `intel_get_project_memory`, секция в `intel_explain_project_state` (в try/except — алерты не роняют диагноз). + +**Проверка (реальные запуски, не моки):** +- `python -m pytest tests/test_alert_store.py tests/test_ui_formatter_memory.py -q` → 28 passed (11 новых AlertStore + 3 форматтера). +- Полный `python -m pytest tests/ -q` → `1689 passed, 5 skipped, 91 deselected` (180.83s). Единственный фейл в первом прогоне — предсуществующий slow-тест `test_health_report.py::test_logs_with_errors` из-за переопределения addopts (`-m 'not slow...'`), не связан с изменением. +- `scripts/architecture_linter.py` → «Все инварианты соблюдены»; ruff: 4 автофикса (W292×2, I001, F401) → чисто; pre-commit 9/9. +- concurrency: 2 потока × 20 alerts → collect не теряет и не дублирует (at-most-once delivery подтверждён). + +**Вердикт:** ✅ ПОДТВЕРЖДЕНА. Вся цепь собрана одним коммитом `1e0c1b4f` (feat(alerts), 10 файлов, +446/−4): AlertStore + 2 генератора + 2 точки доставки + 14 тестов. `verified_from_clean_state: ⚠️ не проверено` (чистый клон не гонялся). + +**Урок:** дедуп по kind+payload с атомарным `collect_and_clear` — правильная гранулярность одноразовых алертов: ловит и «спам на каждый save», и «гонка двух MCP-тулов» одновременно; limit=5 капает токены. Перевод STALE в alert — только на первом переходе, иначе тот же контент (reason меняется на имя файла) становится спамом несмотря на дедуп. + +**Связи:** AGENT_DIARY 2026-09-10 «H1 idle-VOR + system_alerts», KNOWN_ISSUES «Lazy-only верификация» (закрыт), docs/research/universal-engine-study/10-continuous-verification.md (H1/H2/H3). diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index dbc01c9b..12cd84e1 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -110,7 +110,10 @@ - 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). -- **Статус:** ⏳ Open 🟡 — нужен эксперимент (гипотеза → замер → выбор) +- **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-15 · **Owner:** ManSio @@ -122,3 +125,100 @@ - **Статус:** ✅ Fixed (allowed tech debt, deferred refactor; целевые 68 passed, architecture_linter 4/4 OK) - **Дедлайн рефактора:** 2026-10-01 · **Owner:** ManSio +## 2026-09-07 — Lazy-only верификация: VOR вызывается только из intel_get_project_memory, нет TTL/фона + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (H1, 2026-09-09 — фоновая проверка через IdleScheduler-hook; см. основную запись выше) +**Root Cause:** По дизайну (ADR-0003) VOR ленивый, но точки вызова всего одна (layer.py:1097); IdleSch... +- **Статус:** автоматически синхронизировано + + +## 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 — 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... +- **Статус:** автоматически синхронизировано + + +## 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 в мосте. +**Fix:** LANGUAG... +- **Статус:** автоматически синхронизировано + + +## 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 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 diff --git a/README.md b/README.md index 6e35ee14..c93b3f33 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](https://modelcontextprotocol.io/) [![Zed](https://img.shields.io/badge/Zed-extension-orange.svg)](https://zed.dev/) [![CI](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml/badge.svg)](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/badge/tests-1608%20passed-brightgreen)](tests/) +[![Tests](https://img.shields.io/badge/tests-1663%20passed-brightgreen)](tests/) [Features](#-features) • [Quick Start](#-quick-start) • [Tools](#mcp-tools-65-total) • [Documentation](#-documentation-map) • [Installation](docs/en/INSTALL.md) • [Architecture](docs/en/ARCHITECTURE.md) • [Contributing](CONTRIBUTING.md) • [Security](SECURITY.md) diff --git a/src/core/extensions.py b/src/core/extensions.py index fc4c6428..ed743503 100644 --- a/src/core/extensions.py +++ b/src/core/extensions.py @@ -22,7 +22,7 @@ # Core languages (полный граф: chunking + calls + data flow + imports) ".py", ".rs", ".ts", ".tsx", ".js", ".jsx", ".go", ".java", ".cs", ".rb", ".php", ".kt", ".swift", - ".c", ".cpp", ".cxx", ".hpp", ".scala", ".dart", + ".c", ".h", ".cpp", ".cxx", ".hpp", ".scala", ".dart", # Shell (calls + imports, без data flow) ".sh", ".bash", # Context-языки (только AST-парсинг и чанкинг) diff --git a/src/core/indexing/parser.py b/src/core/indexing/parser.py index 97ac3838..62d51349 100644 --- a/src/core/indexing/parser.py +++ b/src/core/indexing/parser.py @@ -74,6 +74,7 @@ ".kt": "kotlin", ".scala": "scala", ".c": "c", + ".h": "c", ".cpp": "cpp", ".cxx": "cpp", ".hpp": "cpp", @@ -328,6 +329,7 @@ def _init_tree_sitter(self): parser = Parser() parser.language = Language(tsc.language()) self.parsers[".c"] = parser + self.parsers[".h"] = parser # C-заголовки парсятся C-грамматикой except ImportError: logger.debug("Tree-sitter C недоступен.") @@ -873,6 +875,7 @@ def _build_chunk_metadata( ".kt": {"import"}, ".swift": {"import_declaration"}, ".c": {"preproc_include"}, + ".h": {"preproc_include"}, ".cpp": {"preproc_include"}, ".cxx": {"preproc_include"}, ".hpp": {"preproc_include"}, @@ -911,6 +914,7 @@ def _build_chunk_metadata( "variable_declaration"}, ".swift": {"property_declaration", "assignment"}, ".c": {"init_declarator", "assignment_expression"}, + ".h": {"init_declarator", "assignment_expression"}, ".cpp": {"init_declarator", "assignment_expression"}, ".cxx": {"init_declarator", "assignment_expression"}, ".hpp": {"init_declarator", "assignment_expression"}, @@ -1030,6 +1034,11 @@ def _build_chunk_metadata( "do_statement", "switch_statement", "case_statement", "conditional_expression", }, + ".h": { + "if_statement", "else_clause", "for_statement", "while_statement", + "do_statement", "switch_statement", "case_statement", + "conditional_expression", + }, ".cpp": { "if_statement", "else_clause", "for_statement", "while_statement", "do_statement", "for_range_loop", "try_statement", "catch_clause", diff --git a/src/core/intelligence/alert_store.py b/src/core/intelligence/alert_store.py new file mode 100644 index 00000000..7b6c8541 --- /dev/null +++ b/src/core/intelligence/alert_store.py @@ -0,0 +1,156 @@ +""" +Alert Store — однократные системные оповещения для агента. + +Закмыкает цепь «файл изменён → STALE → VOR → alert агента» (doc 10 +24-continuous-verification), которой не существовало ни в одном звене +(Exhibit #23, 2026-09-09). + +Отличие от ConsistencyTracker: тот хранит СОСТОЯНИЕ доменов (STALE/CONSISTENT, +событийная модель), а здесь — ДОСТАВЛЯЕМЫЕ оповещения для агента, одноразовые: +после доставки (collect_and_clear) alerts удаляются (Red Team doc 10 §0:4 — +не копить токены в ответах, clear после delivery). + +Источники alerts: + memory_stale — память отмечена STALE после изменения файлов + (notify_change → mark_stale("memory")); следующий VOR-проход + перепроверит узлы, агент должен знать, что результат мог + устареть до этого момента. + memory_starved — узлы памяти видны >=2 циклов VOR, но ни разу не проверены + (MATCHED>0, DELIVERED=0) — систематическое голодание по + бюджету, не разовый вылет (Том, 2026-08-16). + +Хранилище: /projects//intelligence/system_alerts.json +(вне проекта, Задача 4/5) — тот же каталог, что project_memory.json. + +Thread-safety: threading.Lock (НЕ asyncio.Lock) — alerts читаются из нескольких +event-loop'ов (LSP + MCP), asyncio.Lock привязывается к loop-у (см. consistency.py). +""" + +from __future__ import annotations + +import json +import logging +import threading +import time +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional + +__all__ = [ + "AlertStore", + "get_alert_store", +] + +logger = logging.getLogger("MSCodeBase.Intelligence.Alerts") + +_ALERTS_FILE = "system_alerts.json" + +# Канонические kind'ы. +ALERT_KINDS = ("memory_stale", "memory_starved") + + +class AlertStore: + """Хранилище однократных оповещений (thread-safe, per-project). + + Per-project: store_dir выводится из project_path (multi-window, R3TF) — + alerts не смешиваются между окнами. + """ + + def __init__(self, project_path: Path): + from src.core.artifact_paths import get_intelligence_dir + + self.store_dir = get_intelligence_dir(project_path) + self._path = self.store_dir / _ALERTS_FILE + self._lock = threading.Lock() + + # ── запись ──────────────────────────────────────────────────────────── + + def push(self, kind: str, message: str, payload: Optional[Dict[str, Any]] = None) -> None: + """Добавляет alert (дедупликация по kind+payload, без limit-а не спамим).""" + if kind not in ALERT_KINDS: + logger.debug(f"alerts: неизвестный kind '{kind}' (игнор)") + return + with self._lock: + alerts = self._load() + # Дедуп: не плодим одинаковые alerts подряд (одно событие — один alert). + if any(a.get("kind") == kind and a.get("payload") == (payload or {}) for a in alerts): + return + alerts.append( + { + "alert_id": f"ALERT-{uuid.uuid4().hex[:6]}", + "kind": kind, + "message": message, + "payload": payload or {}, + "created_ts": time.time(), + } + ) + self._save(alerts) + + # ── чтение + доставка ───────────────────────────────────────────────── + + def collect_and_clear(self, limit: int = 5) -> List[Dict[str, Any]]: + """Атомарно забирает непрочитанные alerts и удаляет их. + + Одноразовость (Red Team doc 10 §0:4): после доставки alert больше + не существует — следующий вызов его не увидит, токены не копятся. + limit: максимум алертов за одну доставку (защита от токен-оверхеда). + + Thread-safe: вся операция под одним lock — два параллельных + MCP-вызова (memory + explain) не получат один alert дважды: + первый забрал и удалил, второй увидит уже пустой список. + """ + with self._lock: + alerts = self._load() + if not alerts: + return [] + taken = alerts[:limit] + remaining = alerts[limit:] + self._save(remaining) + return taken + + def peek(self) -> List[Dict[str, Any]]: + """Недеструктивный просмотр (для диагностики/health).""" + with self._lock: + return self._load() + + # ── внутреннее ──────────────────────────────────────────────────────── + + def _load(self) -> List[Dict[str, Any]]: + if self._path.exists(): + try: + with open(self._path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + except (json.JSONDecodeError, OSError): + logger.warning("alerts: битый system_alerts.json, сброс", exc_info=True) + return [] + + def _save(self, alerts: List[Dict[str, Any]]) -> None: + try: + self.store_dir.mkdir(parents=True, exist_ok=True) + with open(self._path, "w", encoding="utf-8") as f: + json.dump(alerts, f, ensure_ascii=False, indent=2) + except OSError: + logger.warning("alerts: не удалось записать system_alerts.json", exc_info=True) + + +_locks: Dict[str, threading.Lock] = {} +_lock_guard = threading.Lock() + + +def get_alert_store(project_path: Path) -> AlertStore: + """Per-project синглтон (thread-safe, multi-window R3TF). + + Кэш по resolved project_path — один store на проект, чтобы lock был + общим между вызовами (иначе два AlertStore держат свои locks и гонка + read-clear возвращается). + """ + resolved = str(Path(project_path).resolve()) + global _lock_guard + with _lock_guard: + store = _locks.get(resolved) + if store is None: + store = AlertStore(Path(resolved)) + _locks[resolved] = store + return store diff --git a/src/core/intelligence/layer.py b/src/core/intelligence/layer.py index b1364479..3da3e878 100644 --- a/src/core/intelligence/layer.py +++ b/src/core/intelligence/layer.py @@ -22,7 +22,7 @@ import uuid from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple # Импортируем модули ядра и глобальные настройки from src.core.indexing.indexer import Indexer @@ -995,6 +995,98 @@ async def intel_analyze_incident(self, error_message: str) -> Dict[str, Any]: "similar_incidents": matches[:5], } + def _build_symbol_resolver(self) -> Optional[Callable[[str], Optional[bool]]]: + """Freshness-gated symbol resolver (issue #21/#22, commit B). + + A symbol anchor can REFUTE (False) ONLY when the index is provably fresh: + its recorded build_head equals the live HEAD and the working tree is clean. + Any other state (legacy index w/o build_head, HEAD mismatch, non-git repo, + dirty tree, resolver failure) -> None (INCONCLUSIVE) — absence is then + UNVERIFIABLE, never REFUTED. + Returns: True (referent on disk -> VERIFIED), + False (fresh index, absent -> honest REFUTED), + None (freshness unverifiable -> INCONCLUSIVE). + """ + si = getattr(self, "symbol_index", None) + if si is None: + return None + + def _resolve(qname: str) -> Optional[bool]: + try: + build_fn = getattr(si, "build_head", None) + build_head = build_fn() if build_fn is not None else None + if build_head is None: + return None # legacy/in-memory index -> unknown + from src.core.intelligence.verify_on_read import ( + evaluate_freshness, + resolve_head_dirty, + ) + cur = resolve_head_dirty(self.project_path) + if cur is None: + return None # non-git / unresolvable HEAD + cur_head, dirty = cur + if not evaluate_freshness(build_head, cur_head, dirty): + return None # mismatch / dirty / unknown -> inconclusive + defs = si.find_definitions(qname) + if defs is None: + return None + for d in defs: + fp = getattr(d, "file_path", None) or getattr(d, "file", None) + if fp and Path(fp).exists(): + return True + # Fresh index + clean tree: absence is real evidence. + return False + except Exception: + # Graph/index/git unavailable -> unverifiable -> INCONCLUSIVE. + return None + + return _resolve + + def run_background_verify(self, budget_ms: float = 250.0) -> Optional[Dict[str, Any]]: + """Фоновый VOR-проход (IdleScheduler), без вызова агента. + + Запускается из _check_index_health (task_queue) в фоновом потоке: + общий _write_lock и get_verifier-регистр с agent-путём не конфликтуют + (Red Team a1: lock contention). Пропускает проход, если lock уже занят + — agent-путь приоритетнее. Возвращает stats VOR или None при пропуске. + """ + if self._write_lock.locked(): + logger.debug("[VOR-background] _write_lock занят — проход пропущен") + return None + from src.core.intelligence.verify_on_read import get_verifier + + verifier = get_verifier( + self.project_path, self.store, self._write_lock, + symbol_resolver=self._build_symbol_resolver(), + ) + memory = self.store.load_memory(include_retracted=False) + _memory, stats = verifier.run(memory, budget_ms=budget_ms) + logger.info( + "[VOR-background] seen=%s checked=%s refuted=%s verified=%s latency_ms=%s", + stats.get("nodes_seen"), + stats.get("checked"), + stats.get("refuted"), + stats.get("verified"), + stats.get("latency_ms"), + ) + # system_alerts: idle-проход тоже генерит starved-алерт (агент может + # не вызывать intel_get_project_memory, но алерт мы доставим при первом + # любом tool-реадении). + starved = stats.get("starved_nodes") or [] + if starved: + try: + from src.core.intelligence.alert_store import get_alert_store + + get_alert_store(self.project_path).push( + "memory_starved", + f"{len(starved)} узлов памяти видны ≥2 циклов, " + "но ни разу не проверены (MATCHED>0, DELIVERED=0)", + {"starved_nodes": sorted(starved)[:10]}, + ) + except Exception: # noqa: BLE001 — алерты не роняют фоновый VOR + logger.warning("alerts: не удалось записать memory_starved (bg)", exc_info=True) + return stats + async def intel_get_project_memory( self, include_retracted: bool = False, @@ -1050,49 +1142,7 @@ async def intel_get_project_memory( if verify_on_read and not include_retracted: from src.core.intelligence.verify_on_read import get_verifier - resolver = None - si = getattr(self, "symbol_index", None) - if si is not None: - def _symbol_resolver(qname: str) -> Optional[bool]: - # Freshness-gated symbol resolver (issue #21/#22, commit B). - # A symbol anchor can REFUTE (False) ONLY when the index is - # provably fresh: its recorded build_head equals the live - # HEAD and the working tree is clean. Any other state - # (legacy index w/o build_head, HEAD mismatch, non-git repo, - # dirty tree, resolver failure) -> None (INCONCLUSIVE) — - # absence is then UNVERIFIABLE, never REFUTED. - # Returns: True (referent on disk -> VERIFIED), - # False (fresh index, absent -> honest REFUTED), - # None (freshness unverifiable -> INCONCLUSIVE). - try: - build_fn = getattr(si, "build_head", None) - build_head = build_fn() if build_fn is not None else None - if build_head is None: - return None # legacy/in-memory index -> unknown - from src.core.intelligence.verify_on_read import ( - evaluate_freshness, - resolve_head_dirty, - ) - cur = resolve_head_dirty(self.project_path) - if cur is None: - return None # non-git / unresolvable HEAD - cur_head, dirty = cur - if not evaluate_freshness(build_head, cur_head, dirty): - return None # mismatch / dirty / unknown -> inconclusive - defs = si.find_definitions(qname) - if defs is None: - return None - for d in defs: - fp = getattr(d, "file_path", None) or getattr(d, "file", None) - if fp and Path(fp).exists(): - return True - # Fresh index + clean tree: absence is real evidence. - return False - except Exception: - # Graph/index/git unavailable -> unverifiable -> INCONCLUSIVE. - return None - - resolver = _symbol_resolver + resolver = self._build_symbol_resolver() verifier = get_verifier(target_path, store, self._write_lock, symbol_resolver=resolver) memory, stats = await asyncio.to_thread(verifier.run, memory) @@ -1113,6 +1163,19 @@ def _symbol_resolver(qname: str) -> Optional[bool]: for node in nodes: if node.get("node_id") in starved_ids: node.setdefault("verification", "starved") + # system_alerts: систематическое голодание — агент должен знать + # (одноразовый alert, doc 10 §0.4; дедуп по kind+payload в AlertStore). + try: + from src.core.intelligence.alert_store import get_alert_store + + get_alert_store(target_path).push( + "memory_starved", + f"{len(starved_ids)} узлов памяти видны ≥2 циклов, " + "но ни разу не проверены (MATCHED>0, DELIVERED=0)", + {"starved_nodes": sorted(starved_ids)[:10]}, + ) + except Exception: # noqa: BLE001 — алерты не роняют чтение памяти + logger.warning("alerts: не удалось записать memory_starved", exc_info=True) # Пол Тома: узлы, не проверенные в этом цикле из-за бюджета, # несут устаревший статус — помечаем явно, чтобы потребитель не # принял вчерашний VERIFIED за свежую проверку. diff --git a/src/core/intelligence/tools_reg.py b/src/core/intelligence/tools_reg.py index bf9610ad..970f1a06 100644 --- a/src/core/intelligence/tools_reg.py +++ b/src/core/intelligence/tools_reg.py @@ -7,6 +7,7 @@ import asyncio import logging +from pathlib import Path from typing import Optional from src.utils.i18n import _ @@ -401,9 +402,21 @@ async def get_project_memory( verify_on_read=verify_on_read, project_root=project_root.strip() if project_root and project_root.strip() else None, ) - from src.utils.ui_formatter import format_project_memory - - return format_project_memory(memory, stats=stats, limit=limit) + from src.core.intelligence.alert_store import get_alert_store + from src.utils.ui_formatter import format_project_memory, format_system_alerts + + # system_alerts: однократная доставка перед полным ответом. + # target_path резолвится так же, как в intel_get_project_memory. + target = project_root.strip() if project_root and project_root.strip() else None + _path = intel_layer.project_path + if target: + try: + _path = Path(target).resolve() + except Exception: # noqa: BLE001 + pass + alerts = get_alert_store(_path).collect_and_clear() + out = format_system_alerts(alerts) + return out + format_project_memory(memory, stats=stats, limit=limit) @mcp_app.tool("intel_add_memory_node") async def add_memory_node(section: str, data_json: str, status: str = "ACTIVE") -> str: diff --git a/src/core/task_queue.py b/src/core/task_queue.py index 65ace26c..de2e73c2 100644 --- a/src/core/task_queue.py +++ b/src/core/task_queue.py @@ -365,14 +365,33 @@ def _cpu_available() -> bool: return True +_IDLE_VOR_CALLBACK = None # type: ignore[assignment] + + +def set_idle_vor_callback(fn=None): + """Регистрирует фоновый VOR-проход памяти (инжектируется из серверного слоя). + + task_queue не импортирует слой интеллекта (избегаем cycle import) — только + держит хук-вызов. _check_index_health вызывает его в простое, чтобы память + перепроверялась без агентского вызова intel_get_project_memory. + """ + global _IDLE_VOR_CALLBACK + _IDLE_VOR_CALLBACK = fn + + def _improve_summaries_batch(batch_size: int = 2): """Улучшает summaries для чанков без них (Preemptible).""" logger.debug("[Idle] improve_summaries batch=%s", batch_size) def _check_index_health(): - """Проверка целостности индекса (preemptible).""" + """Проверка целостности индекса + фоновый VOR памяти (preemptible).""" logger.debug("[Idle] check_index_health") + if _IDLE_VOR_CALLBACK is not None: + try: + _IDLE_VOR_CALLBACK() + except Exception as e: + logger.debug("[Idle] VOR callback failed: %s", e) def _update_docs_if_stale(): diff --git a/src/mcp/server_tools.py b/src/mcp/server_tools.py index c85a54a7..e2533b60 100644 --- a/src/mcp/server_tools.py +++ b/src/mcp/server_tools.py @@ -331,6 +331,17 @@ def _register_intelligence_tools(mcp, services): ) register_intelligence_tools(mcp, intel_layer) + # ═══ Фоновый VOR-проход (H1) ═══ + # IdleScheduler: без вызова агента память перепроверяется в фоне, + # чтобы REFUTED/VERIFIED не копились пока agent не дёрнет memory. + try: + from src.core.task_queue import set_idle_vor_callback + + set_idle_vor_callback(intel_layer.run_background_verify) + logger.info(" 🧠 Idle VOR hook registered") + except Exception as vor_e: + logger.info(f" ⚠️ Idle VOR hook skip: {vor_e}") + # ═══ Авто-сбор ADR при старте ═══ # Заполняет project_memory.json архитектурными решениями из git-лога # без необходимости вручную вызывать intel_auto_collect_adrs. @@ -547,6 +558,20 @@ async def intel_explain_project_state(project_root: str = "") -> str: " Run intel_trigger_reindex() then check status via intel_get_job_status()" ) + # system_alerts: однократная доставка (Атомарный collect_and_clear — + # если memory-тул забрал первым, здесь уже пусто). + try: + from src.core.intelligence.alert_store import get_alert_store + from src.utils.ui_formatter import format_system_alerts + + alerts = get_alert_store(target).collect_and_clear() + alert_text = format_system_alerts(alerts) + if alert_text: + lines.append("") + lines.append(alert_text.rstrip()) + except Exception as e: # noqa: BLE001 — алерты не роняют диагноз + logger.warning(f"explain_project_state: alerts недоступны: {e}") + return chr(10).join(lines) # ─── 4. get_runtime_counters ────────────────── diff --git a/src/mcp/tools/indexing_tools.py b/src/mcp/tools/indexing_tools.py index 4c0ea795..0401d3b8 100644 --- a/src/mcp/tools/indexing_tools.py +++ b/src/mcp/tools/indexing_tools.py @@ -67,6 +67,21 @@ async def execute( _tracker = get_consistency_tracker() _tracker.mark_stale("source", f"notify_change: {rel_path_str}") _tracker.mark_stale("index", f"notify_change: {rel_path_str}") + # system_alerts: память тоже могла устареть (файлы меняются) — + # ставим STALE, чтобы следующий VOR-проход перепроверил узлы. + # Алерт — только при ПЕРВОМ переходе в STALE (не спамим на каждый + # notify_change: reason меняется, дедуп AlertStore его пропустит). + memory_state = _tracker.get("memory")["state"] + _tracker.mark_stale("memory", f"notify_change: {rel_path_str}") + if memory_state != "STALE": + from src.core.intelligence.alert_store import get_alert_store + + get_alert_store(project_root).push( + "memory_stale", + "Файлы изменились — память отмечена STALE; узлы будут перепроверены " + "следующим VOR-проходом (cooldown 120s)", + {"reason": f"notify_change: {rel_path_str}"}, + ) except Exception: # noqa: BLE001 — консистентность не блокирует индексацию pass diff --git a/src/utils/ui_formatter.py b/src/utils/ui_formatter.py index b46b876a..94c7eb3d 100644 --- a/src/utils/ui_formatter.py +++ b/src/utils/ui_formatter.py @@ -610,3 +610,26 @@ def _short(s) -> str: result += f"• {k}: {str(v)[:80]}\n" result += "\n" return result + + +def format_system_alerts(alerts: List[Dict]) -> str: + """Рендерит однократные системные оповещения (system_alerts). + + alerts: список из AlertStore.collect_and_clear() — однократные, + после доставки удалены. Пустой список → пустая строка (обратная + совместимость: ответы без alerts не меняются). + + Через i18n, чтобы не размножать строки по тулам. + """ + if not alerts: + return "" + result = _("🚨 **System Alerts**\n\n") + for a in alerts[:5]: + kind = a.get("kind", "?") + message = a.get("message", "") + result += _("• **{kind}:** {message}\n", kind=kind, message=message) + payload = a.get("payload") or {} + for k, v in list(payload.items())[:3]: + result += f" - {k}: {str(v)[:80]}\n" + result += "\n" + return result diff --git a/telemetry/tool_metrics.json b/telemetry/tool_metrics.json index 8ae90938..9e26dfee 100644 --- a/telemetry/tool_metrics.json +++ b/telemetry/tool_metrics.json @@ -1,77 +1 @@ -{ - "lsp_get_diagnostics": { - "calls": 2, - "errors": 0, - "total_ms": 2710, - "min_ms": 708, - "max_ms": 2002, - "last_call": "23:30:53", - "route": {}, - "avg_confidence": 0.0, - "avg_results": 0.0, - "last_detail": "", - "idle_ms": 140229, - "idle_calls": 2, - "repeat_count": 0 - }, - "lsp_document_symbols": { - "calls": 2, - "errors": 0, - "total_ms": 1528, - "min_ms": 525, - "max_ms": 1003, - "last_call": "23:28:35", - "route": {}, - "avg_confidence": 0.0, - "avg_results": 0.0, - "last_detail": "", - "idle_ms": 19960, - "idle_calls": 2, - "repeat_count": 1 - }, - "lsp_find_definition": { - "calls": 1, - "errors": 0, - "total_ms": 524, - "min_ms": 524, - "max_ms": 524, - "last_call": "23:28:43", - "route": {}, - "avg_confidence": 0.0, - "avg_results": 0.0, - "last_detail": "", - "idle_ms": 7149, - "idle_calls": 1, - "repeat_count": 0 - }, - "lsp_find_references": { - "calls": 1, - "errors": 1, - "total_ms": 15004, - "min_ms": 15004, - "max_ms": 15004, - "last_call": "23:29:04", - "route": {}, - "avg_confidence": 0.0, - "avg_results": 0.0, - "last_detail": "", - "idle_ms": 21752, - "idle_calls": 1, - "repeat_count": 0 - }, - "lsp_get_type_info": { - "calls": 2, - "errors": 2, - "total_ms": 30010, - "min_ms": 15005, - "max_ms": 15005, - "last_call": "23:29:48", - "route": {}, - "avg_confidence": 0.0, - "avg_results": 0.0, - "last_detail": "", - "idle_ms": 43177, - "idle_calls": 2, - "repeat_count": 1 - } -} \ No newline at end of file +{} \ No newline at end of file diff --git a/tests/test_alert_store.py b/tests/test_alert_store.py new file mode 100644 index 00000000..462d7f86 --- /dev/null +++ b/tests/test_alert_store.py @@ -0,0 +1,138 @@ +""" +Tests for AlertStore (system_alerts) + доставка. + +Покрытие: +- push/collect_and_clear (одноразовость: второй collect — пусто) +- дедупликация по kind+payload +- limit (доставка не копится — токен-оверхед) +- битый JSON graceful degrade +- гонка двух параллельных collect_and_clear (два threading) +- per-project изоляция (multi-window) +- integer-точечные хуки: mark_stale("memory") в notify_change + доставка в + format_system_alerts (renders), не ломает обратную совместимость. +""" + +from __future__ import annotations + +import threading +from pathlib import Path + +import pytest + +from src.core.intelligence.alert_store import AlertStore, get_alert_store + + +@pytest.fixture +def store(tmp_path: Path) -> AlertStore: + return AlertStore(tmp_path / "proj") + + +def test_push_collect_clear_oneshot(store: AlertStore): + store.push("memory_stale", "msg1") + alerts = store.collect_and_clear() + assert len(alerts) == 1 + assert alerts[0]["kind"] == "memory_stale" + assert alerts[0]["message"] == "msg1" + # Одноразовость: после доставки — пусто. + assert store.collect_and_clear() == [] + assert store.peek() == [] + + +def test_push_dedup_same_payload(store: AlertStore): + store.push("memory_stale", "m", {"reason": "file: a.py"}) + store.push("memory_stale", "m", {"reason": "file: a.py"}) + assert len(store.peek()) == 1 + + +def test_push_distinct_payload_kept(store: AlertStore): + store.push("memory_stale", "m", {"reason": "file: a.py"}) + store.push("memory_stale", "m", {"reason": "file: b.py"}) + assert len(store.peek()) == 2 + + +def test_collect_limit(store: AlertStore): + for i in range(7): + store.push("memory_stale", f"m{i}", {"reason": f"f{i}.py"}) + first = store.collect_and_clear(limit=3) + assert len(first) == 3 + second = store.collect_and_clear(limit=3) + assert len(second) == 3 + third = store.collect_and_clear(limit=3) + assert len(third) == 1 + assert store.collect_and_clear() == [] + + +def test_unknown_kind_ignored(store: AlertStore): + store.push("not_a_kind", "x") + assert store.peek() == [] + + +def test_corrupt_json_graceful(tmp_path: Path): + s = AlertStore(tmp_path / "proj") + (s.store_dir / "system_alerts.json").write_text("{not json", encoding="utf-8") + assert s.peek() == [] # не падает, сброс + s.push("memory_starved", "s") + assert len(s.peek()) == 1 + + +def test_concurrent_collect_single_delivery(tmp_path: Path): + """Гонка: два потока читают alerts одновременно — один alert получил ровно один.""" + s = AlertStore(tmp_path / "proj") + for i in range(20): + s.push("memory_stale", f"m{i}", {"reason": f"f{i}.py"}) + results: list = [] + barrier = threading.Barrier(2) + + def _collect(): + barrier.wait() + results.append(s.collect_and_clear(limit=100)) + + t1 = threading.Thread(target=_collect) + t2 = threading.Thread(target=_collect) + t1.start() + t2.start() + t1.join() + t2.join() + total = len(results[0]) + len(results[1]) + assert total == 20 # ни один alert не потерян и не задвоен + + +def test_per_project_isolation(tmp_path: Path): + s_a = AlertStore(tmp_path / "proj_a") + s_b = AlertStore(tmp_path / "proj_b") + s_a.push("memory_stale", "a") + s_b.push("memory_stale", "b") + assert len(s_a.peek()) == 1 + assert len(s_b.peek()) == 1 + got_a = s_a.collect_and_clear() + assert got_a[0]["message"] == "a" + assert len(s_b.peek()) == 1 # b не тронут + + +def test_get_alert_store_singleton_per_project(tmp_path: Path): + a = get_alert_store(tmp_path / "proj") + b = get_alert_store(tmp_path / "proj") + assert a is b + c = get_alert_store(tmp_path / "proj2") + assert a is not c + + +def test_singleton_cache_key_resolved(tmp_path: Path): + import os + + a = get_alert_store(Path(os.getcwd())) + b = get_alert_store(Path(str(Path(os.getcwd()).resolve()))) + # resolve - один и тот же ключ, один store + assert a is b + + +def test_starved_alert_message(tmp_path: Path): + s = AlertStore(tmp_path / "proj") + s.push( + "memory_starved", + "2 узлов памяти видны ≥2 циклов, но ни разу не проверены", + {"starved_nodes": ["N1", "N2"]}, + ) + alerts = s.collect_and_clear() + assert alerts[0]["kind"] == "memory_starved" + assert alerts[0]["payload"]["starved_nodes"] == ["N1", "N2"] diff --git a/tests/test_symbol_index_call_graph.py b/tests/test_symbol_index_call_graph.py index 5cbf4920..92f3d9fc 100644 --- a/tests/test_symbol_index_call_graph.py +++ b/tests/test_symbol_index_call_graph.py @@ -556,6 +556,17 @@ def test_c_preproc_include(self, live_parser): assert "stdio" in targets assert "mycfg" in targets + def test_h_header_preproc_include(self, live_parser): + """E-S1 следствие: .h-заголовки теперь парсятся C-грамматикой (imports).""" + if not self._has(live_parser, ".h"): + pytest.skip("tree_sitter_c not available") + f = _tmp_file(".h", "#include \n#include \"config.h\"\nvoid init(void);") + imports = live_parser.extract_imports(f) + f.unlink() + targets = {i["target_module"] for i in imports} + assert "stddef" in targets + assert "config" in targets + def test_cpp_preproc_include(self, live_parser): if not self._has(live_parser, ".cpp"): pytest.skip("tree_sitter_cpp not available") diff --git a/tests/test_task_queue.py b/tests/test_task_queue.py index 096a04a9..41620933 100644 --- a/tests/test_task_queue.py +++ b/tests/test_task_queue.py @@ -8,7 +8,7 @@ import pytest -from src.core.task_queue import TaskQueue, TaskStatus +from src.core.task_queue import TaskQueue, TaskStatus, _check_index_health, set_idle_vor_callback def _raise_no_loop(coro, loop): @@ -190,3 +190,37 @@ def worker(): granted = [tid for tid in results if tid is not None] assert len(granted) == 1, f"Ожидался 1 task_id, получено {len(granted)}: {results}" assert queue.has_pending("foo") is True + + +class TestIdleVorHook: + """H1: фоновый VOR-проход из idle-тика (set_idle_vor_callback / _check_index_health).""" + + def test_check_index_health_invokes_callback(self): + """Зарегистрированный hook вызывается в _check_index_health.""" + called = [] + + def fake_vor(): + called.append("vor") + + set_idle_vor_callback(fake_vor) + try: + _check_index_health() + assert called == ["vor"] + finally: + set_idle_vor_callback(None) + + def test_check_index_health_no_callback(self): + """Без hook'а _check_index_health не падает (safe degradate).""" + set_idle_vor_callback(None) + _check_index_health() # не должно бросить + + def test_check_index_health_callback_error_swallowed(self): + """Ошибка внутри hook'а не ломает idle-тик (self-healing).""" + def broken_vor(): + raise RuntimeError("verifier down") + + set_idle_vor_callback(broken_vor) + try: + _check_index_health() # не должно бросить (только log) + finally: + set_idle_vor_callback(None) diff --git a/tests/test_ui_formatter_memory.py b/tests/test_ui_formatter_memory.py index 380e6b3d..433e12f1 100644 --- a/tests/test_ui_formatter_memory.py +++ b/tests/test_ui_formatter_memory.py @@ -171,3 +171,45 @@ def test_no_metrics_no_line(): _mem(1), stats={"verify_on_read": False} ) assert "📊" not in out + + +# ── system_alerts (AlertStore → format_system_alerts) ───────────────────── +from src.utils.ui_formatter import format_system_alerts + + +def test_alerts_empty_returns_empty(): + assert format_system_alerts([]) == "" + assert format_system_alerts(None) == "" + + +def test_alerts_renders_kind_message_payload(): + out = format_system_alerts( + [ + { + "kind": "memory_stale", + "message": "Файлы изменились", + "payload": {"reason": "notify_change: src/a.py"}, + }, + { + "kind": "memory_starved", + "message": "2 узлов starved", + "payload": {"starved_nodes": ["N1", "N2"]}, + }, + ] + ) + assert "🚨 **System Alerts**" in out + assert "memory_stale" in out + assert "notify_change: src/a.py" in out + assert "memory_starved" in out + assert "N1" in out + + +def test_alerts_limit_caps_payload_breadth(): + payload = {f"k{i}": f"v{i}" for i in range(10)} + out = format_system_alerts( + [{"kind": "memory_stale", "message": "m", "payload": payload}] + ) + # Рендерим максимум 3 ключа payload (токен-бюджет). + # k8 не должен встретиться в выводе. + assert "k8" not in out + assert "k1" in out diff --git a/tests/test_verify_on_read.py b/tests/test_verify_on_read.py index 17603dd1..2b151fd4 100644 --- a/tests/test_verify_on_read.py +++ b/tests/test_verify_on_read.py @@ -578,6 +578,46 @@ def test_layer_hook_verify_on_read_off(project: Path): assert layer.store._load_json("project_memory.json")[0].get("status", STATUS_ACTIVE) == STATUS_ACTIVE +# ===================================================================== +# H1: IDLE BACKGROUND VOR (run_background_verify) +# ===================================================================== + + +def test_run_background_verify_refutes_on_idle_pass(project: Path): + """H1: фоновый проход (без вызова агента) отзывает узел с несуществующим импортом.""" + layer = ProjectIntelligenceLayer(project, None, None, None) # type: ignore[arg-type] + layer.store.save_memory([_node("N1", "b", anchors=[{"kind": "import", "value": "grafana"}])]) + + stats = layer.run_background_verify() + assert stats is not None + assert stats["nodes_seen"] == 1 + assert stats["refuted"] == 1 + + raw = layer.store._load_json("project_memory.json")[0] + assert raw["status"] == STATUS_REFUTED + assert raw["retract_source"] == RETRACT_SOURCE + + +def test_run_background_verify_skips_when_lock_held(project: Path): + """H1: при занятом _write_lock фоновый проход пропускается (agent-путь приоритетнее).""" + layer = ProjectIntelligenceLayer(project, None, None, None) # type: ignore[arg-type] + layer.store.save_memory([_node("N1", "b", anchors=[{"kind": "import", "value": "grafana"}])]) + + with layer._write_lock: + stats = layer.run_background_verify() + assert stats is None # lock занят -> skip, без гонки с agent-путём + raw = layer.store._load_json("project_memory.json")[0] + assert raw.get("status", STATUS_ACTIVE) == STATUS_ACTIVE # узел не тронут + + +def test_run_background_verify_empty_memory_returns_none(project: Path): + """H1: пустая память — нечего проверять, фоновый проход деградирует без ошибки.""" + layer = ProjectIntelligenceLayer(project, None, None, None) # type: ignore[arg-type] + stats = layer.run_background_verify() + assert stats is not None # VOR проходит по пустой памяти без сбоя + assert stats["nodes_seen"] == 0 + + def test_write_capture_makes_verify_effective_on_prose(project: Path): """Write-time capture (ADR-0003): prose-claim с 'import X' получает точный якорь при записи -> verify-on-read проверяет его (урок Exp 1-V: голые токены -> артефакты)."""