- 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 по директиве прайса
118 lines
No EOL
5.8 KiB
Python
118 lines
No EOL
5.8 KiB
Python
# -*- 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) |