Merge branch 'KIN-FIX-025-backend_dev'
This commit is contained in:
commit
f1935d2af2
2 changed files with 176 additions and 0 deletions
33
DESIGN.md
33
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 роли = полная софтверная компания.
|
||||
|
|
|
|||
143
tests/test_kin_fix_025_regression.py
Normal file
143
tests/test_kin_fix_025_regression.py
Normal file
|
|
@ -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)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue