simbiot-harness/tests/test_close_stale_failed.py
Simbiot Harness d8a7c89ef9 auto-collapse stale failed (e-261003094432, просьба Alex): failed>24ч → closed архивом
- es: TaskClosed → статус closed (терминальный, обратимый через requeue; closed_by manual|archive)
- commands: close_task (ручной, только failed, идемпотентен), archive_stale_failed(max_age_hours=24, now=None) — демон-архиватор
- scheduler._delivery_and_orphans_loop: каждый проход (15с) архивирует failed>24ч
- requeue принимает closed → ready (доработка из архива)
- cli: simbiot close <id> [-r reason]
- dashboard: порядок ready→running→failed→done→архив, счётчик «архив» в статусбаре, схема закрытых фейлов под одной строкой-счётчиком (тумблер показывает полностью), pill.closed
- тест tests/test_close_stale_failed.py: 15 проверок (закрытие, порог через симуляцию +25ч, done не трогается, идемпотентность, полный цикл closed→ready→failed→closed)
2026-10-03 10:11:40 +00:00

131 lines
No EOL
6.9 KiB
Python
Raw Permalink 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.

"""Авто-схлопывание старых фейлов (просьба Alex 03.10, e-261003094432).
Проверяем три правила новой стейт-машины задач:
1) close_task: failed → closed (терминально, но обратимо через requeue);
близ/close на ready|running|done — ошибка/но-op.
2) archive_stale_failed: failed старше 24ч → closed автоматически,
свежие failed не трогаются.
3) requeue_task: closed → ready (осознанная доработка из архива).
Hermetic: своя БД /tmp/simbiot-test-close-stale.db, подпись — реальный
audit-модуль умеет работать без ключа (событие уходит без sig, verifier
помечает legacy) — это не влияет на проекции.
"""
import os
import sys
import json
import time
os.environ["SIMBIOT_DB"] = "/tmp/simbiot-test-close-stale.db"
# hermeticity: протухшая /tmp-БД не должна влиять
import glob as _glob
for _p in _glob.glob("/tmp/simbiot-test-close-stale.db*"):
os.unlink(_p)
sys.path.insert(0, "/root/simbiot-harness")
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "")
if not cond:
fails.append(name)
import harness.es as es
import harness.commands as cmds
es.init_db()
# ── 1. close_task: failed → closed ──────────────────────────────────────
tid, _size = cmds.submit_task("тест close: failed-задача", "details", minutes=5)
es.append_event("task", tid, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
es.append_event("task", tid, "TaskFailed", {"error": "timeout"})
model = es.run_projections()
check("1: подготовка — задача failed", model["tasks"][tid]["status"] == "failed")
cmds.close_task(tid, "тестовое закрытие")
model = es.run_projections()
check("1: failed → closed", model["tasks"][tid]["status"] == "closed", model["tasks"][tid]["status"])
check("1: closed_by=manual", model["tasks"][tid].get("closed_by") == "manual")
tid2 = cmds.close_task(tid, "повторное закрытие")
check("1: повторный close — no-op (идемпотентно)",
model["tasks"][tid]["status"] == "closed" and tid2 == tid)
# close на не-failed:
tid_r, _size = cmds.submit_task("тест close: ready-задача", "details", minutes=5)
try:
cmds.close_task(tid_r, "не должен пройти")
check("1: close на ready отклонён", False)
except ValueError as e:
check("1: close на ready отклонён", "close только для failed" in str(e), str(e)[:100])
# ── 2. archive_stale_failed: только failed>24ч ──────────────────────────
from datetime import datetime, timezone, timedelta
now0 = datetime.now(timezone.utc)
old_id, fresh_id = "t-old-failed", "t-fresh-failed"
# порог тестируем через now=now0+25ч (симуляция «прошли сутки» — события
# подделывать не нужно: now — параметр архиватора, добавлен для тестов)
es.append_event("task", old_id, "TaskSubmitted",
{"title": "старый дубли-фейл", "details": "", "size": "S"})
es.append_event("task", old_id, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
es.append_event("task", old_id, "TaskFailed", {"error": "timeout"})
# свежий фейл — тоже с реальным timestamp; в фазе A возраст ~0 у обоих
es.append_event("task", fresh_id, "TaskSubmitted",
{"title": "свежий фейл", "details": "", "size": "S"})
es.append_event("task", fresh_id, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
es.append_event("task", fresh_id, "TaskFailed", {"error": "boom"})
model = es.run_projections()
check("2: подготовка — оба failed",
model["tasks"][old_id]["status"] == "failed" and model["tasks"][fresh_id]["status"] == "failed")
# фаза A: реальное now, порог 24ч — возраст секунд, никого не трогает
closed = cmds.archive_stale_failed(max_age_hours=24.0)
check("2: свежие failed не архивируются (порог 24ч, возраст ~0)",
not closed, str(closed))
check("2: свежий остался failed", es.run_projections()["tasks"][fresh_id]["status"] == "failed")
# фаза B: сутки спустя (симуляция) — оба failed>24ч уходят в closed
sim = now0 + timedelta(hours=25)
closed = cmds.archive_stale_failed(max_age_hours=24.0, now=sim)
check("2: сутки спустя оба failed>24ч → closed",
set(closed) == {old_id, fresh_id}, str(closed))
m2 = es.run_projections()
check("2: оба в closed", m2["tasks"][old_id]["status"] == "closed"
and m2["tasks"][fresh_id]["status"] == "closed")
check("2: closed_by=archive", m2["tasks"][old_id].get("closed_by") == "archive")
# повторный проход — no-op (уже closed, не failed)
closed = cmds.archive_stale_failed(max_age_hours=24.0, now=sim)
check("2: повторный проход — пусто (идемпотентно)", not closed, str(closed))
# done-задачи архиватор не трогает
tid_d, _size = cmds.submit_task("тест close: done", "details", minutes=5)
es.append_event("task", tid_d, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
es.append_event("task", tid_d, "TaskCompleted", {"result_ref": "r", "answer": "ok"})
cmds.archive_stale_failed(max_age_hours=0.001)
check("2: done не тронут", es.run_projections()["tasks"][tid_d]["status"] == "done")
# ── 3. requeue из closed → ready ────────────────────────────────────────
cmds.requeue_task(old_id, "понадобилась снова")
m3 = es.run_projections()
check("3: closed → ready через requeue", m3["tasks"][old_id]["status"] == "ready",
m3["tasks"][old_id]["status"])
# и цепочка full-loop: ready → claim → failed → close(no-op, уже closed-история? нет:
# requeue вернул в ready, поэтому снова failed → close проходит)
es.append_event("task", old_id, "TaskClaimed", {"level": "hour", "session_id": "s-test"})
es.append_event("task", old_id, "TaskFailed", {"error": "timeout2"})
cmds.close_task(old_id, "снова в архив")
check("3: полный цикл closed→ready→failed→closed",
es.run_projections()["tasks"][old_id]["status"] == "closed")
print()
if fails:
print("FAILS:", len(fails))
sys.exit(1)
print("OK: авто-схлопывание старых фейлов — все проверки прошли")