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:
Simbiot Harness 2026-10-03 10:11:40 +00:00
parent 03d3dffeb8
commit d8a7c89ef9
7 changed files with 264 additions and 10 deletions

10
cli.py
View file

@ -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)

View file

@ -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 = новая работа над
# задачей, её результат должен уйти в чат заново → сбрасываем факт

View file

@ -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>`;
// эскалации

View file

@ -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),

View file

@ -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:

View file

@ -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:

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