simbiot-harness/tests/test_guard_ready_race.py
Simbiot Harness a0179e5df7 fix(guard)+feat(dash): requeue ready-гонка не валила тик (e-261001083543-0eab6d); дашборд «🔧 в работе» + elapsed (t-261001082111); пакет покупателя предпродажи (t-261001085722)
- scheduler: guard «бюджет исчерпан» вынесен в _guard_budget_stub_requeue()
  — ValueError requeue_task при ready (задача уже возвращена task_promoted
  путём того же тика) гасится, тик дофинализируется; тесты
  tests/test_guard_ready_race.py (FAILS 0), test_budget_stub_guard зелёный;
- es: TaskClaimed → task.claimed_at; TaskPromoted(requeue) → claimed_at=None;
- dashboard.py/.html: API отдаёт claimed_at; бейдж «🔧 в работе: hour · 12м»
  (минуты/секунды/часы), подсветка running-строки, «взята в работу» в деталях;
- docs/PACKAGE-SIMBIOT-PRESALE.md: состав пакета подписки (EN-блок для
  лендинга, состояние компонентов), цена единая $99.9 по директиве прайса
2026-10-01 09:11:23 +00:00

118 lines
No EOL
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""Guard requeue при ready-статусе — регресс e-261001083543-0eab6d.
Сценарий падения min-тика 09:00: claim → воркер умер → exception-путь
написал TaskPromoted (задача уже ready) → guard-фаза «бюджет исчерпан»
вызвала requeue_task → ValueError «requeue не нужен» → тик упал,
дайджест/спенд/доставed остальных задач пачки потеряны.
Проверяет: _guard_budget_stub_requeue со смешанной пачкой
(failed / ready / done) НЕ бросает исключений, failed уходит в ready,
TaskCompleted не пишется, тик продолжает финализацию.
"""
import os
import sys
_DST = "/tmp/simbiot-test-guard-ready.db"
for p in (_DST, _DST + "-wal", _DST + "-shm"):
if os.path.exists(p):
os.unlink(p)
os.environ["SIMBIOT_DB"] = _DST
sys.path.insert(0, "/root/simbiot-harness")
from harness import es
from harness import commands
from harness.scheduler import _guard_budget_stub_requeue
es.init_db()
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "")
if not cond:
fails.append(name)
def task_state(tid):
model = es.run_projections()
t = model["tasks"].get(tid)
return t["status"] if t else None
def task_events(tid):
db = es._conn()
try:
rows = db.execute("SELECT event_type FROM events WHERE aggregate_id=?", (tid,)).fetchall()
return [r["event_type"] for r in rows]
finally:
db.close()
stub = "тик завершён: бюджет шагов исчерпан (30 tool-вызовов)."
# Пачка: три задачи в РАЗНЫХ статусах — как в реальном сбойном тике:
# failed — orphan-обработчик успел TaskFailed;
# ready — exception-путь уже вернул claim в очередь (TaskPromoted);
# running — обычный случай, guard должен вернуть в ready.
tid_failed, _ = commands.submit_task(title="пачка: failed", minutes=5, details="")
es.append_event("task", tid_failed, "TaskClaimed", {"level": "min", "session_id": "s-a"})
es.append_event("task", tid_failed, "TaskFailed", {"error": "симуляция: воркер умер после claim"})
es.run_projections()
check("0.1: подготовка failed", task_state(tid_failed) == "failed", task_state(tid_failed))
tid_ready, _ = commands.submit_task(title="пачка: ready", minutes=5, details="")
es.append_event("task", tid_ready, "TaskClaimed", {"level": "min", "session_id": "s-a"})
es.run_projections()
es.append_event("task", tid_ready, "TaskPromoted", {"to": "S", "from": "S",
"reason": "симуляция: exception-путь вернул claim в очередь"})
es.run_projections()
check("0.2: подготовка ready", task_state(tid_ready) == "ready", task_state(tid_ready))
tid_running, _ = commands.submit_task(title="пачка: running", minutes=5, details="")
es.append_event("task", tid_running, "TaskClaimed", {"level": "min", "session_id": "s-a"})
es.run_projections()
check("0.3: подготовка running", task_state(tid_running) == "running", task_state(tid_running))
batch = [tid_failed, tid_ready, tid_running]
# ── 1. Главный регресс: guard на смешанной пачке НЕ бросает ──
out_text, out_ids = _guard_budget_stub_requeue(batch, stub)
check("1.0: нет исключения на ready-члене пачки", out_text == stub and out_ids == [])
check("1.1: failed → ready", task_state(tid_failed) == "ready", task_state(tid_failed))
check("1.2: running → ready", task_state(tid_running) == "ready", task_state(tid_running))
check("1.3: ready остаётся ready (без второго TaskPromoted от guard)",
task_events(tid_ready).count("TaskPromoted") == 1, task_events(tid_ready))
check("1.4: TaskCompleted не пишется guard-ом",
all("TaskCompleted" not in task_events(t) for t in batch))
# ── 2. Идемпотентность: повторный guard по той же пачке тоже без исключений ──
_guard_budget_stub_requeue(batch, stub)
check("2.0: повторный guard не валился", task_state(tid_running) == "ready")
# ── 3. Пустой claim → no-op ──
t2, i2 = _guard_budget_stub_requeue([], stub)
check("3.0: пустая пачка — no-op", t2 == stub and i2 == [])
# ── 4. Сценарий end-to-end уровня: run_level_tick не падает на guard-фазе ──
# Готовим пачку с ready-членом и напрямую вызываем guard-путь run_level_tick
# через monkeypatch: executor подменяем на стаб-воркера со стаб-стоп-фразой.
tid_e2e, _ = commands.submit_task(title="e2e: guard не валил тик", minutes=5, details="")
es.append_event("task", tid_e2e, "TaskClaimed", {"level": "min", "session_id": "s-e2e"})
es.append_event("task", tid_e2e, "TaskPromoted", {"to": "S", "from": "S",
"reason": "симуляция: task_promoted в том же тике"})
es.run_projections()
check("4.0: e2e задача в ready", task_state(tid_e2e) == "ready")
# run_level_tick вызывает executor внутри — guard-путь тестируем напрямую (§1),
# здесь проверяем лишь тестируемость точки вызова
import harness.scheduler as sch
import inspect
src = inspect.getsource(sch.run_level_tick)
check("4.1: guard в run_level_tick идёт через функцию (тестируемо)",
"_guard_budget_stub_requeue" in src)
print()
print("FAILS:", len(fails))
sys.exit(1 if fails else 0)