Skip to content
Merged
54 changes: 44 additions & 10 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@ MSCodeBase pre-commit hook — автоматическая проверка п
8. check_known_issues — §4.8 R4: размер ≤ 300 строк + архивация старых записей
"""

import os
import subprocess
import sys
from pathlib import Path

# Per-gate budget. 900s is the historical value (documented flakiness at 300s);
# overridable only to make the timeout path testable without a 15-minute wait.
GATE_TIMEOUT = int(os.environ.get("MSCB_PRECOMMIT_GATE_TIMEOUT", "900"))

# Indirection so a test can stub THIS module's spawn without patching the
# global `subprocess` module — a global patch leaks into every other test in
# the same worker (CI: 14 unrelated failures).
Popen = subprocess.Popen


# §9 п.9 (ENCODING SAFETY): при выводе emoji-строк из stdout скриптов
# (например, «📊 Итог: 20 ✅ / 1 ❌») в cp1251-консоль падает
Expand All @@ -42,7 +52,7 @@ def find_project_root() -> Path | None:
return None


def run_script(script_path: str, label: str) -> bool:
def run_script(script_path: str, label: str, extra_args: list[str] | None = None) -> bool:
"""Запускает скрипт и возвращает True если успешно."""
project_root = find_project_root()
if project_root is None:
Expand All @@ -54,10 +64,13 @@ def run_script(script_path: str, label: str) -> bool:
print(f" ⏭️ {label}: скрипт не найден ({script})")
return True

# Печатаем ДО запуска: гейт с большим бюджетом (verify_diary тянет полный
# pytest) иначе выглядит как зависание — и его обходят через --no-verify.
print(f" ⏳ {label}: выполняется (бюджет {GATE_TIMEOUT}s)…", flush=True)
# §5.16: Popen + communicate (не capture_output) — защита от pipe-deadlock
# в фоновых потоках; encoding="utf-8" — декодирование stdout в utf-8.
proc = subprocess.Popen(
[sys.executable, str(script)],
proc = Popen(
[sys.executable, str(script), *(extra_args or [])],
cwd=str(project_root),
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
Expand All @@ -69,22 +82,43 @@ def run_script(script_path: str, label: str) -> bool:
# нагрузкой) — кап 120s давал флаки TimeoutExpired на коммитах (2026-08-08);
# 300→900 (2026-08-24): сюита выросла (live-sync + predict-наборы), 300s
# начал флакать при параллельной нагрузке.
stdout, _ = proc.communicate(timeout=900)
try:
stdout, _ = proc.communicate(timeout=GATE_TIMEOUT)
except subprocess.TimeoutExpired:
# Раньше это было необработанное исключение: хук умирал с трейсбеком и
# БЕЗ вердикта — его путали с «гейт сломан» и обходили через --no-verify.
# Таймаут — это вердикт: называем гейт, который не ответил.
proc.kill()
proc.communicate()
print(f" ❌ {label}: TIMEOUT >{GATE_TIMEOUT}s — гейт не ответил, вердикта нет",
flush=True)
return False
if proc.returncode != 0:
print(f" ❌ {label}: exit {proc.returncode}")
print(f" ❌ {label}: exit {proc.returncode}", flush=True)
if stdout:
for line in stdout.splitlines()[-10:]:
print(f" {line}")
return False
print(f" ✅ {label}: OK")
print(f" ✅ {label}: OK", flush=True)
return True


def main():
print("🔍 MSCodeBase pre-commit checks:")
print("🔍 MSCodeBase pre-commit checks:", flush=True)
all_ok = True

all_ok &= run_script("scripts/verify_diary.py", "verify_diary")
# gate-zero тянет полный `pytest tests/`. Измерено 2026-10-03 на этой машине:
# 36% за 600s, то есть ~1670s — бюджет гейта (900s) не выдерживает НИ ОДНОГО
# коммита. Раньше это выглядело как «хук висит», и коммиты уходили в
# --no-verify. Поэтому fast-режим: полный прогон остаётся за CI, а локальный
# коммит говорит ВСЛУХ, что именно пропущено.
fast = os.environ.get("MSCB_PRECOMMIT_FAST") == "1"
diary_args = ["--skip-gate-zero"] if fast else None
if fast:
print(" ⚠️ FAST: полный `pytest tests/` (gate-zero) ПРОПУЩЕН — "
"покрытие этого коммита обязана взять CI.", flush=True)

all_ok &= run_script("scripts/verify_diary.py", "verify_diary", diary_args)
all_ok &= run_script("scripts/stale_detector.py", "stale_detector")
all_ok &= run_script("scripts/check_tool_names.py", "check_tool_names")
all_ok &= run_script("scripts/negative_controls_runner.py", "negative_controls")
Expand All @@ -97,9 +131,9 @@ def main():
all_ok &= run_script("scripts/ruff_gate.py", "ruff_gate")

if not all_ok:
print("\n❌ Pre-commit checks FAILED. Исправьте ошибки перед коммитом.")
print("\n❌ Pre-commit checks FAILED. Исправьте ошибки перед коммитом.", flush=True)
sys.exit(1)
print("\n✅ All pre-commit checks passed.")
print("\n✅ All pre-commit checks passed.", flush=True)
sys.exit(0)


Expand Down
25 changes: 24 additions & 1 deletion AGENT_DIARY.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


## [2026-10-03] P-020 / P-021 — гонка между тестами, непригодный гейт, реестр вне репозитория

- **P-020 (Verified):** `test_negative_controls_runner.py` доказывал digest-pinning **правкой настоящей фикстуры** `dead_guard.py` + restore в `finally`. Под `-n auto` соседний воркер читал digest в окне между записью и restore → `unproven=1` на CI при зелёном локально. **Все ручные проверки проходили, потому что проверяли байты, а триггер — параллелизм.** Фикс: scratch-копия + контроль `PROVEN` до мутации.
Expand Down Expand Up @@ -866,3 +866,26 @@ chunk_index -(20_000_000+line), graph_score=0.4 (ниже функций 1.0). E
**Конкуренция агентов (важно):** параллельная сессия **активно пишет в это же рабочее дерево** — `tools/verification/verify_public_claims.py` записан 03.10 **08:38:16** (проверено `LastWriteTime`). `tools/knowledge/` тоже появлялся/исчезал в нём. **Не коммитить, пока это не согласовано:** мой `git status` загрязнён чужими файлами. Их файл реализует §19.11/T11 «каждое опубликованное число + команда, которая перевыводит его сегодня» = **мой E-2 уже в работе у них** → следующий агент не должен дублировать.
**Открыто владельцу:** (1) `tools/verification/` неслитая ветка vs активная сессия; (2) чужое в дереве (`experiments/prompt_robustness/**` изменён, `results/` untracked); (3) `KNOWN_ISSUES.md` перевалил 300 строк → нужна ротация; (4) ни одного коммита не сделано.
**Открыто владельцу:** (1) запускать ли P1/E-1/E-5; (2) коммитить ли `frozen/`+отчёт — в дереве лежит **чужое** (`experiments/prompt_robustness/` изменён, `results/` untracked), не моё, не откатывал (§19.9); (3) разрешение на запись в реестр `P-###` вне репозитория.

## [2026-10-03] Гейт отвечал rc=2 на любой вход + мёртвое число A10 без метки (Fixed)
**Status:** ⚠️ Частично. Код и тесты зелёные; полный прогон с нуля не делался (нет venv в worktree).
**Root Cause (двойной, независимый):** (1) `tools/verification/gates.py:322` звал `fn(**kw)` без отбора по сигнатуре гейта; MCP-тул `gate` шлёт superset полей всем четырём гейтам → `TypeError` → верхний обработчик печатал traceback и exit 2, то есть «гейт недоступен» вместо вердикта. Гипотеза «PATH/GitBash, как в AGENT_DIARY.md:451» — **REFUTED**: в `tools/verification/` нет резолвера bash. (2) T-17 висел открытым: число `orphan 30s→120ms` публиковалось в 3 файлах с меткой «подтверждено», хотя `claims_audit/RESULTS.md` A10 = NOT REPRODUCED (путь удалён по дизайну, R3TF).
**Fix:** `run()` выбирает параметры своего гейта по `inspect.signature`, отброшенные ключи возвращает как `ignored_kwargs`, а отсутствующий обязательный ключ отвечает `UNKNOWN`/rc=2 с перечнем `required_kwargs` вместо трайсбека. A10 помечен `SUPERSEDED` (`EXPERIMENTS_LOG.md:1320`, `experiments/README.md:27`, `experiments/lock_zombie/README.md:9`) со sha `3798d6a9` и ссылкой на R3TF `7974d981`.
**Guard:** `tests/test_gates_kwarg_contract.py` (10 тестов). **Negative control:** на исходном коде падают 4 из них, на исправленном 10/10 — тесты умеют падать. Red team: опечатка в ключе → `UNKNOWN`, а не тихий ALLOW; чужой гейт → `UNKNOWN`; отброшенный ключ по построению не может изменить вердикт, поэтому понижения нет — решение записано в коде.
**Self-caught:** первая запись в дневник переписала файл целиком (CRLF→LF, +928/−872 в diff) — поймано на `git diff --stat` до коммита, откачено через `git checkout --`, перезаписано с CRLF.
**Не сделано (честно):** портфолио (`exp-10`, репозиторий `MSPortfolio`) публикует то же число как действующую практику — отсюда не правится; полный `pytest tests/` и `verify_clean_state.sh` не гонял (в worktree нет venv, использована venv основного дерева). Реестр ловушек вне рабочего каталога — P-16 предложен в `tools/knowledge/PENDING_LEDGER.md`, не внесён.
## [2026-10-03] Коммит с `--no-verify`: обоснование (Fixed, с исключением)
**Решение владельца (цитата):** «(б) сначала короткая диагностика hook, при невозможности быстрого исправления — (а) `--no-verify` с обязательным обоснованием».
**Диагностика (что успел исключить, 1 команда):** `where bash` → единственный `bash` в PATH — `C:\Windows\System32\bash.exe`, то есть WSL-шим; при прямом вызове печатает баннер «WSL не установлен/требует wsl.exe --update» и **возвращается**, а не виснет. GitBash `C:\Program Files\Git\bin\bash.exe` существует и отвечает. **Значит блокер не в bash-резолвере** (и это опять опровергает версию из P-450/AGENT_DIARY:451 как достаточное объяснение).
**Что осталось невыясненным (честно):** `.githooks/pre-commit` при запуске `python -u` не печатает **ни байта** и не завершается за 100 с; после принудительного завершения остаются висящие `python.exe` (6 шт.). Локализовать, какой из 10 гейтов виснет, не успел: дальше требовалось бы трогать инфраструктуру хуков и убивать чужие процессы (в т.ч. параллельной сессии — §21.2 запрещает).
**Почему это не моя правка:** (1) коммит не проходит и у параллельной сессии — их же дневник, запись 03.10: «ни одного коммита не сделано»; (2) хук не печатает ни строки, то есть падает до вывода, а мои файлы — `tools/verification/gates.py` + новый тест; (3) `gates.py` хуком не вызывается (единственный импорт — мой тест).
**Чем заменена проверка:** `tests/test_gates_kwarg_contract.py` — 10 passed; **negative control:** 4 из 10 падают на коде до правки; `--selftest` PASSED; `tools/verification/heldout_cli_contract.py` — «every documented invocation works»; ruff чист; `scripts/check_parallel_sessions.py` — blocking=0. То есть пропущены именно 10 гейтов хука, а не тесты.
**Хеши закоммиченного:** `tools/verification/gates.py` blob `53bb7cfc15be27ebbb49bf0a65057bb8dfa2ec64`, `tests/test_gates_kwarg_contract.py` blob `b135dda9ba92b97827cb300ecabb7b8d517ef0cf`.
**Открыто:** починка самого pre-commit hook — отдельная задача; считать ветку готовой к мержу без прогонa хука нельзя.
## [2026-10-03] Хук, ruff-гейт и парсер вердикта: коммит снова возможен (Fixed)
**Status:** ✅ Код зелёный, 10/10 гейтов ✅ (exit 0, 12 с). Не проверено: полный `pytest tests/` без fast-режима (~28 мин) и прогон с нуля.
**Root Cause (три независимых, каждый маскировал следующий):** (1) `.githooks/pre-commit` печатал вердикт только после гейта, а `communicate(timeout=900)` бросал необработанный `TimeoutExpired` → медленный гейт = трейсбек без вердикта = «хук сломан»; (2) `verify_diary.py` кап 900s против измеренных ~1670s на полный `pytest tests/` (36% за 600s) → коммит невозможен в принципе; (3) `ruff_gate` звал `python -m ruff`, а в venv пакет ruff 0.15.22 **без `__main__`** → выход 1 с пустым выводом, вечно красный и немой гейт.
**Fix:** прогресс-строка до запуска + таймаут как вердикт с именем гейта + `MSCB_PRECOMMIT_GATE_TIMEOUT`/`MSCB_GATE_ZERO_TIMEOUT`/`MSCB_PRECOMMIT_FAST`; `ruff_cmd()` резолвит живой ruff через `--version`-пробу; пустой вывод при ненулевом коде называется вслух как сломанный запуск, а не lint. Отдельно: `_parse_verdict` судьи вынесен в `scripts/judge_verdict.py`, обе копии (2 из 2) импортируют её — багованная `reconstruct_judge_cot.py` была без тестов.
**Guard:** `tests/test_precommit_hook_contract.py` (7), `tests/test_ruff_gate.py (+2 кейса), `tests/test_reconstruct_judge_verdict.py` (11). **Negative control:** pre-fix хук грузится из git и обязан **не уметь** ответить на таймаут; старый парсер судьи обязаны падать 7 тестов (проверено: 7 failed → 11 passed).
**Self-caught (третье за сессию, все три — моя измерительная ошибка, не дефект системы):** (а) «хук висит» = мой диагностический окно короче первого гейта; (б) «verify_diary не печатает» = буферизация `| Select-Object`; (в) «нет вывода» = то же. Общий класс: инструмент, измеряющий инструмент, молчит так же, как инструмент. Правило: прежде чем назвать что-то сломанным, проверить, что моя труба не съела вывод.
**Не сделано:** причина 3-кратного замедления сюиты не расследована; fast-режим hook — осознанный компромисс и может стать привычкой; полный прогон оставлен CI.
3 changes: 2 additions & 1 deletion EXPERIMENTS_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1317,7 +1317,8 @@ zombie_probe: holder pid=12684 alive=False -> STALE (после выхода с
```
**Before (та же сессия, старый код):** контеншн = 30.0s → RuntimeError (замерено в Exp B); orphan-кейс не детектился (30s → RuntimeError); free ~9ms; stale ~33ms.

**Вердикт:** ПОДТВЕРЖДЕНА. orphan: 30000ms → 120ms (terminate+steal, вкл. TerminateProcess реального python + ретрай-unlink); healthy: 30000ms RuntimeError → 1512ms LockBusyError (wait=1.5 в бенче; прод-дефолт 8.0s); free/stale без изменений (7/31ms). Дополнительно verified: после TerminateProcess реального python'а venvlauncher-обёртка умирает сама (никаких висящих процессов), lock перезаписывается нашим PID.
**Вердикт (2026-08-08):** ПОДТВЕРЖДЕНА. **Статус публикации (2026-10-03):** `SUPERSEDED` — измерено на `3798d6a9`, а путь достижимости удалён по дизайну: ORPHAN-ветка вырезана из прод-MCP по R3TF (`7974d981`, `src/core/indexing/database_lock.py:81-84`). Сегодняшней командой не воспроизводится (`experiments/claims_audit/RESULTS.md` A10 = NOT REPRODUCED), поэтому число остаётся записью прогона, а не текущим фактом. Не переписывать.
orphan: 30000ms → 120ms (terminate+steal, вкл. TerminateProcess реального python + ретрай-unlink); healthy: 30000ms RuntimeError → 1512ms LockBusyError (wait=1.5 в бенче; прод-дефолт 8.0s); free/stale без изменений (7/31ms). Дополнительно verified: после TerminateProcess реального python'а venvlauncher-обёртка умирает сама (никаких висящих процессов), lock перезаписывается нашим PID.
**Урок:** (1) TerminateProcess синхронный, но файловый дескриптор lock'а умирающего процесса даёт PermissionError на unlink → нужен _unlink_with_retry (иначе краш в кейсе «только что убитый holder»); (2) venvlauncher: lock пишет РЕАЛЬНЫЙ python (os.getpid() внутри скрипта), terminate по pid из lock убивает именно держателя, обёртка умирает следом — прод-механизм работоспособен.

---
Expand Down
Loading
Loading