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)
This commit is contained in:
parent
03d3dffeb8
commit
d8a7c89ef9
7 changed files with 264 additions and 10 deletions
10
cli.py
10
cli.py
|
|
@ -29,6 +29,10 @@ def main():
|
|||
p_rq.add_argument("task_id")
|
||||
p_rq.add_argument("-r", "--reason", default="manual requeue")
|
||||
|
||||
p_cl = sub.add_parser("close", help="закрыть failed-задачу без работы (архив); failed>24ч схлопываются автоматически")
|
||||
p_cl.add_argument("task_id")
|
||||
p_cl.add_argument("-r", "--reason", default="manual close")
|
||||
|
||||
p_cmp = sub.add_parser("complete", help="закрыть running-задачу с результатом (батч-тик, t-260930101325)")
|
||||
p_cmp.add_argument("task_id")
|
||||
p_cmp.add_argument("-a", "--answer", required=True, help="результат задачи")
|
||||
|
|
@ -181,6 +185,12 @@ def main():
|
|||
args.task_id, args.answer, level="manual")
|
||||
print(f"OK: задача {tid} завершена"
|
||||
+ (" (доставлено в TG)" if delivered else ""))
|
||||
elif args.cmd == "close":
|
||||
# авто-схлопывание старых фейлов (e-261003094432): failed → closed,
|
||||
# ручное закрытие + демон-архиватор (failed>24ч автоматически)
|
||||
es.init_db()
|
||||
tid = commands.close_task(args.task_id, args.reason)
|
||||
print(f"OK: задача {tid} закрыта (closed — архив; requeue вернёт в очередь)")
|
||||
elif args.cmd == "promote":
|
||||
es.init_db()
|
||||
tid, new_size = commands.promote_task(args.task_id, args.reason)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,57 @@ def promote_task(task_id: str, reason: str = "manual promote"):
|
|||
return task_id, new_size
|
||||
|
||||
|
||||
def close_task(task_id: str, reason: str = "manual close") -> str:
|
||||
"""Закрыть failed-задачу без работы: TaskClosed → статус closed.
|
||||
|
||||
Авто-схлопывание старых фейлов (просьба Alex 03.10, e-261003094432):
|
||||
дубли и прах не должны висеть красной группой на доске. closed —
|
||||
терминальный, НО обратимый: requeue принимает closed → ready.
|
||||
Идемпотентно: уже closed/done — просто возвращаем задачу (no-op).
|
||||
"""
|
||||
model = es.run_projections()
|
||||
t = model["tasks"].get(task_id)
|
||||
if t is None:
|
||||
raise ValueError(f"задача {task_id} не найдена")
|
||||
if t["status"] in ("closed", "done"):
|
||||
return task_id # уже закрыта — no-op (идемпотентность)
|
||||
if t["status"] != "failed":
|
||||
raise ValueError(
|
||||
f"задача {task_id} в статусе {t['status']} — close только для failed; "
|
||||
f"для живой работы используй requeue, для завершения — complete")
|
||||
es.append_event("task", task_id, "TaskClosed", {"by": "manual", "reason": reason[:300]})
|
||||
return task_id
|
||||
|
||||
|
||||
def archive_stale_failed(max_age_hours: float = 24.0, now=None) -> list:
|
||||
"""Демон-архиватор (e-261003094432): failed старше max_age_hours → closed.
|
||||
|
||||
Зовёт scheduler-демон каждые DELIVERY_POLL: красная группа фейлов
|
||||
не растёт — старше суток уходят в «архив» (статус closed), где их
|
||||
видно при желании, но они не забивают доску. requeue возвращает
|
||||
закрытую в очередь, если она всё-таки нужна.
|
||||
now — для тестов (симуляция «прошло 24ч» без подделки событий).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
model = es.run_projections()
|
||||
now = now or datetime.now(timezone.utc)
|
||||
closed = []
|
||||
for tid, t in (model.get("tasks") or {}).items():
|
||||
if t.get("status") != "failed":
|
||||
continue
|
||||
fin = t.get("finished") or ""
|
||||
try:
|
||||
age = (now - datetime.fromisoformat(fin)) if fin else None
|
||||
except ValueError:
|
||||
age = None
|
||||
if age is None or age.total_seconds() < max_age_hours * 3600:
|
||||
continue
|
||||
es.append_event("task", tid, "TaskClosed", {
|
||||
"by": "archive", "reason": f"auto: failed > {max_age_hours:g}ч без работы"})
|
||||
closed.append(tid)
|
||||
return closed
|
||||
|
||||
|
||||
def requeue_task(task_id: str, reason: str = "manual requeue"):
|
||||
"""Вернуть failed/running/прерванную задачу в очередь (TaskPromoted → ready).
|
||||
|
||||
|
|
@ -106,7 +157,9 @@ def requeue_task(task_id: str, reason: str = "manual requeue"):
|
|||
t = model["tasks"].get(task_id)
|
||||
if t is None:
|
||||
raise ValueError(f"задача {task_id} не найдена")
|
||||
if t["status"] not in ("failed", "done", "running"):
|
||||
# closed тоже возвращаем (авто-схлопывание старых фейлов, e-261003094432):
|
||||
# закрытая в архив задача может понадобиться снова — осознанная доработка.
|
||||
if t["status"] not in ("failed", "closed", "done", "running"):
|
||||
raise ValueError(f"задача {task_id} в статусе {t['status']} — requeue не нужен")
|
||||
# идемпотентная доставка (t-260930083937): requeue = новая работа над
|
||||
# задачей, её результат должен уйти в чат заново → сбрасываем факт
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
.pill.running{background:var(--ink);color:var(--paper)}
|
||||
.pill.failed{background:#fdeaec;border-color:var(--err);color:var(--err)}
|
||||
.pill.done{background:var(--card);color:var(--dim)}
|
||||
.pill.closed{background:#efefe9;color:#8a867c;border-color:#d8d5cb}
|
||||
.pill.working{background:var(--signal);border-color:var(--signal);color:#fff;cursor:default}
|
||||
tr.task.running td{background:#fff3ea}
|
||||
.sizeS{color:var(--ok)} .sizeM{color:var(--warn)} .sizeL{color:var(--signal)} .sizeXL{color:var(--err)}
|
||||
|
|
@ -142,7 +143,7 @@
|
|||
<div id="ladder"><div class="skel">загружаю…</div></div>
|
||||
|
||||
<h2>Задачи <span class="m dim" id="taskcount"></span></h2>
|
||||
<div class="lead">сортировка: ready → running → failed → done, внутри — новые сверху · <span class="hint">клик по строке — детали задачи</span></div>
|
||||
<div class="lead">сортировка: ready → running → failed → done → архив · failed старше 24ч автоматически уезжают в архив (closed) · <span class="hint">клик по строке — детали задачи</span></div>
|
||||
<div id="tasks"><div class="skel">загружаю…</div></div>
|
||||
|
||||
<h2>Открытые эскалации <span class="m dim" id="esccount"></span></h2>
|
||||
|
|
@ -258,6 +259,22 @@ function size(s){ return `<span class="pill size${esc(s)}">${esc(s)}</span>`; }
|
|||
|
||||
// детали задачи по клику (т-260929220004): раскрытие строки без перезагрузки
|
||||
const openTasks = new Set(); // id раскрытых строк (переживает автообновление)
|
||||
// t-261003091019-7ab074 + авто-схлопывание (просьба Alex, e-261003094432):
|
||||
// failed старше 24ч демон-архиватор переводит в closed — «архив» внизу доски
|
||||
// (после done). Пока не закрыты (≤ проход демона, 15с), живут на rank 3.45.
|
||||
// Схлопывание одной строкой-счётчиком; тумблер oldFailedExpanded честно
|
||||
// показывает всё скрытое (openTasks переживает автообновление).
|
||||
let oldFailedExpanded = false;
|
||||
function toggleCollapse(){ oldFailedExpanded = !oldFailedExpanded; if (typeof tick === "function" && document.getElementById("view-dash")?.classList.contains("active")) tick(); }
|
||||
function failedAgeH(t){
|
||||
const ts = (t.status === "failed" && t.finished) ? new Date(t.finished) : null;
|
||||
return (ts && !isNaN(ts)) ? (Date.now() - ts.getTime()) / 36e5 : 0;
|
||||
}
|
||||
function taskRank(t){
|
||||
if(t.status === "failed" && failedAgeH(t) > 24) return 3.45; // до прохода архиватора: после done(3), до closed(4)
|
||||
const o = {ready:0, running:1, failed:2, done:3, closed:4};
|
||||
return o[t.status] ?? 9;
|
||||
}
|
||||
function toggleTask(id){
|
||||
if(openTasks.has(id)) openTasks.delete(id); else openTasks.add(id);
|
||||
const el = document.getElementById("tr-" + id);
|
||||
|
|
@ -310,6 +327,7 @@ function render(s){
|
|||
<div class="stat hot"><div class="n">${st.running || 0}</div><div class="l">running</div></div>
|
||||
<div class="stat"><div class="n">${st.failed || 0}</div><div class="l">failed</div></div>
|
||||
<div class="stat"><div class="n">${st.done || 0}</div><div class="l">done</div></div>
|
||||
<div class="stat"><div class="n">${st.closed || 0}</div><div class="l">архив</div></div>
|
||||
<div class="stat"><div class="n">${c.escalations_open || 0}</div><div class="l">эскалации</div></div>
|
||||
<div class="stat"><div class="n">${c.digests_total || 0}</div><div class="l">дайджестов</div></div>`;
|
||||
|
||||
|
|
@ -331,18 +349,41 @@ function render(s){
|
|||
|
||||
// задачи (клик по строке — детали, т-260929220004)
|
||||
$("taskcount").textContent = `(${s.tasks.length})`;
|
||||
$("tasks").innerHTML = s.tasks.length ? `<table>
|
||||
const s0 = [...s.tasks];
|
||||
// порядок: ready → running → failed → done → архив (closed).
|
||||
// t-261003091019-7ab074: failed старше 24ч до прохода архиватора живут
|
||||
// на rank 3.45 (сразу под done); архиватор переводит их в closed.
|
||||
s0.sort((a, b) =>
|
||||
(b.created || "").localeCompare(a.created || "") || taskRank(a) - taskRank(b));
|
||||
s0.sort((a, b) => taskRank(a) - taskRank(b));
|
||||
// авто-схлопывание (просьба Alex, e-261003094432): архив = closed
|
||||
// (+ переходные failed>24ч до ближайшего прохода архиватора, ≤15с)
|
||||
// прячется под одной строкой-счётчиком в самом низу доски.
|
||||
const oldFailed = s0.filter(t => t.status === "closed"
|
||||
|| (t.status === "failed" && failedAgeH(t) > 24));
|
||||
const freshTasks = oldFailed.length ? s0.filter(t => !oldFailed.includes(t)) : s0;
|
||||
const collapseRow = (oldFailed.length && !oldFailedExpanded) ? `<tr class="task" id="tr-old-failed" onclick="toggleCollapse()">
|
||||
<td class="arrow">▶</td>
|
||||
<td class="m dim">архив</td>
|
||||
<td><b>${oldFailed.length} failed/closed старше 24ч</b> — показать</td>
|
||||
<td></td><td></td><td></td><td></td>
|
||||
</tr>` : "";
|
||||
const shownTasks = oldFailedExpanded ? s0 : [...freshTasks];
|
||||
const errPreview = t => (t.error && t.status !== "closed" && !(t.status === "failed" && failedAgeH(t) > 24))
|
||||
? `<br><span class="m" style="color:var(--err)">${esc(String(t.error).slice(0,140))}</span>` : "";
|
||||
$("tasks").innerHTML = s0.length ? `<table>
|
||||
<tr><th></th><th>id</th><th>задача</th><th>размер</th><th>статус</th><th>уровень</th><th>создана</th></tr>
|
||||
${s.tasks.map(t => `<tr class="task${openTasks.has(t.id) ? " open" : ""}" id="tr-${esc(t.id)}" onclick="toggleTask('${esc(t.id)}')">
|
||||
${shownTasks.map(t => `<tr class="task${openTasks.has(t.id) ? " open" : ""}" id="tr-${esc(t.id)}" onclick="toggleTask('${esc(t.id)}')">
|
||||
<td class="arrow">▶</td>
|
||||
<td class="m dim">${esc(t.id)}</td>
|
||||
<td><b>${esc(t.title)}</b>${t.error ? `<br><span class="m" style="color:var(--err)">${esc(String(t.error).slice(0,140))}</span>` : ""}</td>
|
||||
<td><b>${esc(t.title)}</b>${errPreview(t)}</td>
|
||||
<td>${size(t.size)}</td>
|
||||
<td>${pill(t.status)}${workingTag(t)}</td>
|
||||
<td>${pill(t.status)}${t.status === "failed" && failedAgeH(t) > 24 ? `<span class="m dim">· ${Math.floor(failedAgeH(t))}ч</span>` : ""}${workingTag(t)}</td>
|
||||
<td class="m">${esc(t.level || "—")}</td>
|
||||
<td class="m dim">${fmtTime(t.created)}</td>
|
||||
</tr>
|
||||
${taskDetail(t)}`).join("")}</table>`
|
||||
${taskDetail(t)}`).join("")}
|
||||
${collapseRow}</table>`
|
||||
: `<div class="skel">задач нет</div>`;
|
||||
|
||||
// эскалации
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ _HEALTH_PORT = int(os.environ.get("SIMBIOT_DASH_HEALTH_PORT", "8766"))
|
|||
_TOKEN = os.environ.get("SIMBIOT_DASH_TOKEN", "")
|
||||
_REFRESH = int(os.environ.get("SIMBIOT_DASH_REFRESH", "5"))
|
||||
|
||||
_STATUS_ORDER = {"ready": 0, "running": 1, "failed": 2, "done": 3}
|
||||
_STATUS_ORDER = {"ready": 0, "running": 1, "failed": 2, "done": 3, "closed": 4}
|
||||
_SIZE_COLOR = {"S": "#1d7a3e", "M": "#a3690f", "L": "#FF4F00", "XL": "#b3261e"}
|
||||
|
||||
# ── чат Simbiot прямо в дашборде (t-261001100740-99d55e) ──────────────────
|
||||
|
|
@ -250,7 +250,8 @@ def build_state() -> dict:
|
|||
"report": (report.get("text") or "")[:1500],
|
||||
})
|
||||
# два стабильных прохода: новые сверху внутри статуса,
|
||||
# статусы по порядку ready→running→failed→done
|
||||
# статусы по порядку ready→running→failed→done→closed (авто-архив
|
||||
# старых фейлов, e-261003094432 — failed>24ч уезжает в closed)
|
||||
tasks.sort(key=lambda x: x.get("created") or "", reverse=True)
|
||||
tasks.sort(key=lambda x: _STATUS_ORDER.get(x["status"], 9))
|
||||
|
||||
|
|
@ -290,7 +291,7 @@ def build_state() -> dict:
|
|||
"tasks_total": len(tasks),
|
||||
"tasks_by_status": {
|
||||
s: sum(1 for t in tasks if t["status"] == s)
|
||||
for s in ("ready", "running", "failed", "done")
|
||||
for s in ("ready", "running", "failed", "done", "closed")
|
||||
},
|
||||
"tasks_by_status_size": counts,
|
||||
"escalations_open": len(escalations),
|
||||
|
|
|
|||
|
|
@ -394,6 +394,15 @@ def _apply_event(model: dict, ev: dict):
|
|||
t = model["tasks"].get(aid)
|
||||
if t:
|
||||
t.update(status="failed", error=data.get("error"), finished=ev["timestamp"])
|
||||
elif etype == "TaskClosed":
|
||||
# Авто-схлопывание старых фейлов (просьба Alex 03.10, e-261003094432):
|
||||
# failed-задачи старше FAILED_STALE_HOURS закрываются демон-архиватором
|
||||
# в статус closed — отдельная «архивная» группа после done. Обратимо:
|
||||
# requeue принимает closed → ready (осознанная доработка).
|
||||
t = model["tasks"].get(aid)
|
||||
if t and t.get("status") in ("failed",):
|
||||
t.update(status="closed", finished=ev["timestamp"],
|
||||
closed_by=data.get("by") or "archive")
|
||||
elif etype == "TaskPromoted":
|
||||
t = model["tasks"].get(aid)
|
||||
if t:
|
||||
|
|
|
|||
|
|
@ -866,6 +866,15 @@ def _delivery_and_orphans_loop():
|
|||
deliver_pending()
|
||||
except Exception:
|
||||
pass # демон не должен умирать: следующий проход попробует снова
|
||||
# авто-схлопывание старых фейлов (e-261003094432, просьба Alex):
|
||||
# failed старше 24ч → closed («архив» после done на доске), доска
|
||||
# не зарастает красной группой; requeue вернёт, если нужна снова.
|
||||
try:
|
||||
closed = commands.archive_stale_failed()
|
||||
for tid in closed:
|
||||
_log(f"archive: {tid} failed>24ч → closed")
|
||||
except Exception:
|
||||
pass
|
||||
# инициативные реплики (t-260930073419): без LLM, ритм внутри себя;
|
||||
# ошибки глотаем — реплика подождёт следующего прохода, демон жив
|
||||
try:
|
||||
|
|
|
|||
131
tests/test_close_stale_failed.py
Normal file
131
tests/test_close_stale_failed.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Авто-схлопывание старых фейлов (просьба 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: авто-схлопывание старых фейлов — все проверки прошли")
|
||||
Loading…
Reference in a new issue