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
+
${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) ? `
+ | ▶ |
+ архив |
+ ${oldFailed.length} failed/closed старше 24ч — показать |
+ | | | |
+
` : "";
+ 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 ? `
| id | задача | размер | статус | уровень | создана |
- ${s.tasks.map(t => `
+ ${shownTasks.map(t => `
| ▶ |
${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("")}
`
+ ${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