diff --git a/cli.py b/cli.py index 7236d98..b9ea22d 100644 --- a/cli.py +++ b/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) diff --git a/harness/commands.py b/harness/commands.py index a5ae5d1..667ab4b 100644 --- a/harness/commands.py +++ b/harness/commands.py @@ -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 = новая работа над # задачей, её результат должен уйти в чат заново → сбрасываем факт diff --git a/harness/dashboard.html b/harness/dashboard.html index 2983ccb..078cdf7 100644 --- a/harness/dashboard.html +++ b/harness/dashboard.html @@ -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 @@
загружаю…

Задачи

-
сортировка: ready → running → failed → done, внутри — новые сверху · клик по строке — детали задачи
+
сортировка: ready → running → failed → done → архив · failed старше 24ч автоматически уезжают в архив (closed) · клик по строке — детали задачи
загружаю…

Открытые эскалации

@@ -258,6 +259,22 @@ function size(s){ return `${esc(s)}`; } // детали задачи по клику (т-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){
${st.running || 0}
running
${st.failed || 0}
failed
${st.done || 0}
done
+
${st.closed || 0}
архив
${c.escalations_open || 0}
эскалации
${c.digests_total || 0}
дайджестов
`; @@ -331,18 +349,41 @@ function render(s){ // задачи (клик по строке — детали, т-260929220004) $("taskcount").textContent = `(${s.tasks.length})`; - $("tasks").innerHTML = s.tasks.length ? ` + 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) ? ` + + + + + ` : ""; + const shownTasks = oldFailedExpanded ? s0 : [...freshTasks]; + const errPreview = t => (t.error && t.status !== "closed" && !(t.status === "failed" && failedAgeH(t) > 24)) + ? `
${esc(String(t.error).slice(0,140))}` : ""; + $("tasks").innerHTML = s0.length ? `
▶архив${oldFailed.length} failed/closed старше 24ч — показать
- ${s.tasks.map(t => ` + ${shownTasks.map(t => ` - + - + - ${taskDetail(t)}`).join("")}
idзадачаразмерстатусуровеньсоздана
▶ ${esc(t.id)}${esc(t.title)}${t.error ? `
${esc(String(t.error).slice(0,140))}` : ""}
${esc(t.title)}${errPreview(t)} ${size(t.size)}${pill(t.status)}${workingTag(t)}${pill(t.status)}${t.status === "failed" && failedAgeH(t) > 24 ? `· ${Math.floor(failedAgeH(t))}ч` : ""}${workingTag(t)} ${esc(t.level || "—")} ${fmtTime(t.created)}
` + ${taskDetail(t)}`).join("")} + ${collapseRow}` : `
задач нет
`; // эскалации diff --git a/harness/dashboard.py b/harness/dashboard.py index e80b5e6..6dd73c7 100644 --- a/harness/dashboard.py +++ b/harness/dashboard.py @@ -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), diff --git a/harness/es.py b/harness/es.py index 46b85e4..c962ca3 100644 --- a/harness/es.py +++ b/harness/es.py @@ -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: diff --git a/harness/scheduler.py b/harness/scheduler.py index 11bbe0b..dc59609 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -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: diff --git a/tests/test_close_stale_failed.py b/tests/test_close_stale_failed.py new file mode 100644 index 0000000..768aa56 --- /dev/null +++ b/tests/test_close_stale_failed.py @@ -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: авто-схлопывание старых фейлов — все проверки прошли") \ No newline at end of file