"""Авто-схлопывание старых фейлов (просьба 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: авто-схлопывание старых фейлов — все проверки прошли")