From 544175e9eba84c409a0835b5df4df4fa7f41bc9e Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 11:59:22 +0200 Subject: [PATCH] kin: KIN-FIX-025-backend_dev --- DESIGN.md | 33 +++++++ tests/test_kin_fix_025_regression.py | 143 +++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/test_kin_fix_025_regression.py diff --git a/DESIGN.md b/DESIGN.md index 8d7aff2..3e60a99 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1280,6 +1280,39 @@ Real-time через SSE (Server-Sent Events) — runner пишет лог → A --- +## ЧАСТЬ 5: Критические ограничения реализации + +### 5.1 Запрет блокирующего time.sleep в pipeline execution thread + +**Корень бага KIN-145.** Pipeline агентов выполняется в отдельном потоке (ThreadPoolExecutor или threading.Thread). Этот поток является единственным для всего пайплайна задачи. Любой блокирующий вызов внутри него блокирует весь runner-процесс на время сна. + +**Механизм каскадного падения:** +1. `runner.py` вызывает `merge_worktree(worktree_path, p_path, max_retries=3, retry_delay_s=15)` (старый баг) +2. При git-конфликте `merge_worktree` вызывает `time.sleep(15)` трижды → 45 секунд блокировки +3. Родительский web-сервер (FastAPI) теряет ответ от воркера +4. `_check_parent_alive()` во всех других пайплайнах видит `ESRCH` (процесс недоступен) или таймаут +5. Все in-flight пайплайны помечаются как `failed` → массовое падение всех задач во всех проектах + +**Почему retry для git-конфликтов бессмысленен:** +Git merge conflict — детерминированная ошибка. Конфликт возник потому что два ветки расходятся в одном месте файла. Повторная попытка через 15 секунд не изменит содержимое файлов и снова завершится конфликтом. Retry тут не помогает, только блокирует. + +**Допустимые альтернативы в зависимости от контекста:** +- `async`-задержка (`asyncio.sleep`) — если код работает в async-окружении +- отдельный поток (`threading.Thread`) — если нужен polling или retry с задержкой +- отказ от retry — для детерминированных ошибок (git merge conflict, validation error) +- `Event.wait(timeout)` — если нужно ждать внешнего события без CPU spin + +**Правило:** +> `time.sleep()` в `agents/` ЗАПРЕЩЁН без исключений. +> В `core/` разрешён только в файлах с явным daemon-thread или retry-контекстом: +> — `core/watchdog.py` (daemon-поток, отдельный от pipeline) +> — `core/worktree.py` (retry-delay, вызывается только с явными kwargs, не из runner.py) + +**Static regression guard:** `tests/test_kin_fix_025_regression.py` сканирует `agents/` и `core/` +на наличие `time.sleep` — любой новый вызов вне allowlist ломает тест. + +--- + ## Заметки **Архитектура:** Изоляция контекста через процессы. Decisions = внешняя память PM. PM тупой/памятливый, workers умные/забывчивые. context-builder фильтрует по роли. ~22 роли = полная софтверная компания. diff --git a/tests/test_kin_fix_025_regression.py b/tests/test_kin_fix_025_regression.py new file mode 100644 index 0000000..c5f8791 --- /dev/null +++ b/tests/test_kin_fix_025_regression.py @@ -0,0 +1,143 @@ +"""Regression tests for KIN-FIX-025 — запрет time.sleep в pipeline execution thread. + +Корень бага KIN-145: blocking sleep в pipeline-треде останавливает весь runner-процесс. +При краше родителя через _check_parent_alive роняет все in-flight pipeline. + +Правило: + - agents/ — time.sleep ЗАПРЕЩЁН без исключений + - core/ — time.sleep разрешён только в явно задокументированных файлах (ALLOWLIST) + +ALLOWLIST (core-файлы с законным time.sleep): + - core/watchdog.py — daemon-поток, работает независимо от pipeline, sleep-цикл штатный + - core/worktree.py — retry-delay, вызывается только с explicit max_retries kwargs, + runner.py НЕ передаёт эти kwargs (guard: test_KIN-145_regression.py) + +Тест сканирует все .py файлы в core/ и agents/, исключая test_*.py и *.pyc. +Падает при нахождении time.sleep вне allowlist, выводя путь и строку нарушения. +""" + +import re +from pathlib import Path + +import pytest + + +# ───────────────────────────────────────────────────────────────────────────── +# Конфигурация +# ───────────────────────────────────────────────────────────────────────────── + +PROJECT_ROOT = Path(__file__).parent.parent + +# Файлы core/, в которых time.sleep допустим (с задокументированной причиной) +CORE_SLEEP_ALLOWLIST: set[str] = { + "core/watchdog.py", # daemon-поток: sleep-цикл мониторинга, не pipeline + "core/worktree.py", # retry-delay: вызывается с explicit kwargs, НЕ из runner.py +} + +SLEEP_PATTERN = re.compile(r"\btime\.sleep\s*\(") + + +def _find_sleep_violations(directory: Path, allowlist: set[str]) -> list[str]: + """Ищет time.sleep в .py файлах директории, возвращает список нарушений.""" + violations = [] + for py_file in sorted(directory.rglob("*.py")): + # Исключаем тест-файлы + if py_file.name.startswith("test_"): + continue + # Нормализуем путь относительно проекта для сравнения с allowlist + rel_path = py_file.relative_to(PROJECT_ROOT) + rel_str = str(rel_path).replace("\\", "/") + if rel_str in allowlist: + continue + # Сканируем строки файла + try: + lines = py_file.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + continue + for lineno, line in enumerate(lines, start=1): + if SLEEP_PATTERN.search(line): + violations.append(f"{rel_str}:{lineno}: {line.strip()}") + return violations + + +# ───────────────────────────────────────────────────────────────────────────── +# Тест 1: agents/ — time.sleep запрещён полностью +# ───────────────────────────────────────────────────────────────────────────── + +class TestNoSleepInAgents: + def test_no_time_sleep_in_agents(self): + """KIN-FIX-025: agents/ не должен содержать time.sleep. + + Blocking sleep в pipeline-треде → каскадное падение через _check_parent_alive. + Допустимые альтернативы: asyncio.sleep, threading.Event.wait, отдельный поток. + """ + agents_dir = PROJECT_ROOT / "agents" + violations = _find_sleep_violations(agents_dir, allowlist=set()) + + assert not violations, ( + "НАРУШЕНИЕ KIN-FIX-025: time.sleep найден в agents/ (pipeline thread).\n" + "Blocking sleep в pipeline-треде → каскадное падение всех in-flight задач.\n" + "Используй asyncio.sleep / threading.Event.wait / отдельный поток.\n" + "Нарушения:\n" + "\n".join(f" {v}" for v in violations) + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Тест 2: core/ — time.sleep запрещён вне allowlist +# ───────────────────────────────────────────────────────────────────────────── + +class TestNoSleepInCoreOutsideAllowlist: + def test_no_time_sleep_in_core_outside_allowlist(self): + """KIN-FIX-025: core/ не должен содержать time.sleep вне CORE_SLEEP_ALLOWLIST. + + Разрешённые исключения (CORE_SLEEP_ALLOWLIST): + - core/watchdog.py — daemon-поток, не pipeline + - core/worktree.py — retry-delay с explicit kwargs + + Любой новый time.sleep в core/ требует явного добавления в allowlist + с документированным обоснованием. + """ + core_dir = PROJECT_ROOT / "core" + violations = _find_sleep_violations(core_dir, allowlist=CORE_SLEEP_ALLOWLIST) + + assert not violations, ( + "НАРУШЕНИЕ KIN-FIX-025: time.sleep найден в core/ вне CORE_SLEEP_ALLOWLIST.\n" + "Если sleep необходим — добавь файл в CORE_SLEEP_ALLOWLIST в этом тест-файле\n" + "с задокументированным обоснованием (daemon-поток или retry с explicit kwargs).\n" + "Нарушения:\n" + "\n".join(f" {v}" for v in violations) + ) + + def test_allowlisted_files_still_exist(self): + """KIN-FIX-025: файлы из CORE_SLEEP_ALLOWLIST должны существовать. + + Если файл удалён — убери его из allowlist, чтобы allowlist не гнил. + """ + missing = [] + for rel_path in CORE_SLEEP_ALLOWLIST: + full_path = PROJECT_ROOT / rel_path + if not full_path.exists(): + missing.append(rel_path) + + assert not missing, ( + "Файлы из CORE_SLEEP_ALLOWLIST не найдены — удали их из allowlist:\n" + + "\n".join(f" {p}" for p in missing) + ) + + def test_allowlisted_files_actually_contain_sleep(self): + """KIN-FIX-025: файлы из CORE_SLEEP_ALLOWLIST должны реально содержать time.sleep. + + Если sleep убрали — убери файл из allowlist, чтобы allowlist не стал dead weight. + """ + stale = [] + for rel_path in CORE_SLEEP_ALLOWLIST: + full_path = PROJECT_ROOT / rel_path + if not full_path.exists(): + continue # покрывается тестом выше + content = full_path.read_text(encoding="utf-8") + if not SLEEP_PATTERN.search(content): + stale.append(rel_path) + + assert not stale, ( + "Файлы из CORE_SLEEP_ALLOWLIST не содержат time.sleep — удали их из allowlist:\n" + + "\n".join(f" {p}" for p in stale) + )