diff --git a/harness/commands.py b/harness/commands.py index 26cc3e1..a5ae5d1 100644 --- a/harness/commands.py +++ b/harness/commands.py @@ -138,6 +138,27 @@ def mark_awaiting_delivery(task_id: str, reason: str = "", reply_to: str = None) return task_id +import re as _re + +# Guard (e-261001074527-f0c1b4): ночной тик 20727 пачкой закрыл 5 L-задач +# шаблонной отпиской воркера «бюджет исчерпан» — это стоп-фраза тика, а не +# результат. Такие «результаты» закрывать задачу не могут. +_BUDGET_STUB_RE = _re.compile(r"бюджет\s+(?:шагов\s+)?исчерпан", _re.IGNORECASE) +_BUDGET_STUB_MARKERS_RE = _re.compile(r"тик\s+завершён|tool-вызов|шагов", _re.IGNORECASE) + + +def looks_like_budget_stub(answer: str) -> bool: + """True, если answer — шаблонная отписка воркера о бюджете шагов. + + Требование маркера («тик завершён», «tool-вызов», «шагов») отсекает + ложные срабатывания: честный результат работы, где по делу упомянут + исчерпанный бюджет («проверил, бюджет месяца исчерпан»), под гвард + не попадает — а в стоп-фразе воркера маркер всегда есть. + """ + s = answer or "" + return bool(_BUDGET_STUB_RE.search(s) and _BUDGET_STUB_MARKERS_RE.search(s)) + + def complete_task_with_delivery(task_id: str, answer: str, level: str, result_ref: str = None): """TaskCompleted + немедленная доставка результата в TG-чат. @@ -157,6 +178,12 @@ def complete_task_with_delivery(task_id: str, answer: str, level: str, raise ValueError(f"задача {task_id} в статусе {t['status']} — завершать можно только running") if (answer or "").strip() == "": raise ValueError("пустой результат — доставлять нечего, заверши как failed/requeue") + if looks_like_budget_stub(answer): + requeue_task(task_id, reason="guard: 'бюджет исчерпан' — не результат, задача в ready (инцидент e-261001074527)") + raise ValueError( + f"задача {task_id} ПЕРЕВОДЖЕНА в ready: отпиской «бюджет исчерпан» " + f"задачу закрывать нельзя. Либо доделай и завершай с реальным " + f"результатом, либо явно requeue/failed.") # TaskCompleted — независимо от доставки (событие-истина о завершении) es.append_event("task", task_id, "TaskCompleted", { "result_ref": result_ref, "answer": answer[:2000], diff --git a/harness/scheduler.py b/harness/scheduler.py index f50bf69..dbdf161 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -468,6 +468,14 @@ def run_level_tick(level_cfg, seq: int, model: dict): # исполнитель пишет в дайджесте). Доставка — той же командой # commands.deliver_result (идемпотентна: без флага/уже sent — Skip). # Ответ урезаем на задачу, чтобы 6 задач не раздули 6×2000. + # GUARD (e-261001074527-f0c1b4): финальный текст тика «бюджет исчерпан» — + # стоп-сообщение воркера, а не результат; ночь 20727 им фейково закрылись + # 5 L-задач. Такой тик работы не завершал — задачи возвращаются в ready. + from .commands import looks_like_budget_stub, requeue_task as _requeue + if (text or "") and looks_like_budget_stub(text): + for tid in (claimed or []): + _requeue(tid, reason="guard: финал тика «бюджет исчерпан» — работа не завершена, задача в ready (e-261001074527)") + return text, [] for tid in (claimed or []): es.append_event("task", tid, "TaskCompleted", { "result_ref": trace_id, "answer": (text or "")[:2000], diff --git a/tests/test_budget_stub_guard.py b/tests/test_budget_stub_guard.py new file mode 100644 index 0000000..1cd9bd5 --- /dev/null +++ b/tests/test_budget_stub_guard.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""Guard бюджет-стабов (e-261001074527-f0c1b4, hermetic, без LLM/TG). + +Запуск: ./venv/bin/python tests/test_budget_stub_guard.py + +Ночной тик 20727 пачкой закрыл 5 L-задач отпиской воркера «бюджет исчерпан». +Гвард: complete_task_with_delivery со стоп-фразой → ValueError + авто-requeue +в ready (TaskPromoted), TaskCompleted НЕ пишется. Честные результаты со +встречным упоминанием бюджета гвард не трогает. + +Проверяет: looks_like_budget_stub на ночных текстах (стоп-фраза воркера, +end_turn-сообщение tool-budget, шаблон ночной доставки) — true; честные +результаты («уточнил бюджет», «бюджет рекламной кампании исчерпан») — false; +complete со стабом → ValueError + задача вернулась в ready; повторный +complete с реальным результатом → done. CLI requeue по guard-сообщению +работает (задача не теряется). +""" +import os +import sys + +_DST = "/tmp/simbiot-test-guard.db" +for p in (_DST, _DST + "-wal", _DST + "-shm"): + if os.path.exists(p): + os.unlink(p) +os.environ["SIMBIOT_DB"] = _DST +sys.path.insert(0, "/root/simbiot-harness") + +from harness import es +from harness import commands + +es.init_db() + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "") + if not cond: + fails.append(name) + + +def task_state(tid): + model = es.run_projections() + t = model["tasks"].get(tid) + return t["status"] if t else None + + +def task_events(tid): + db = es._conn() + try: + rows = db.execute("SELECT event_type FROM events WHERE aggregate_id=?", (tid,)).fetchall() + return [r["event_type"] for r in rows] + finally: + db.close() + + +# ── 1. Predicate: ночные шаблоны распознаются ── +nightly = [ + "✅ Готово, тик завершён: бюджет исчерпан", + "тик завершён: бюджет шагов исчерпан (30 tool-вызовов). Выше — результат того, что успело выполниться.", + "тиковый бюджет шагов исчерпан (30 tool-вызовов): вызов отменён; новые вызовы инструментов отключены до конца тика", +] +for i, txt in enumerate(nightly): + check(f"1.{i}: stub распознан", commands.looks_like_budget_stub(txt), txt[:50]) + +# ── 2. Predicate: честные результаты не трогаем ── +honest = [ + "Утверждён оффер: 10 мест × $99.9/мес. Задача закрыта, счётчик на лендинг — следующая задача.", + "Проверил рекламный кабинет: бюджет рекламной кампании исчерпан, переключил на резервный.", + "Проанализировал траты за месяц; бюджет месяца исчерпан частично — отчёт готов.", + "Рефакторинг завершён из 3 шагов. Всё влезло, бюджет не трогали.", +] +for i, txt in enumerate(honest): + check(f"2.{i}: честный не стаб", not commands.looks_like_budget_stub(txt), txt[:50]) + +# ── 3. Живая задача: complete со стабом → ValueError + requeue в ready ── +tid, _sz = commands.submit_task(title="Тест guard", minutes=30, + details="тестовая задача гвирда бюджет-стабов") +es.append_event("task", tid, "TaskClaimed", {"level": "hour", "session_id": "t-guard"}) +es.run_projections() +check("3.0: задача running", task_state(tid) == "running") + +stub = "тик завершён: бюджет исчерпан" +try: + commands.complete_task_with_delivery(tid, stub, level="hour") + check("3.1: complete со стабом отклонён", False, "нет ValueError") +except ValueError as e: + check("3.1: complete со стабом отклонён", "ПЕРЕВЕД" in str(e).upper() or "ready" in str(e), str(e)[:80]) + +check("3.2: после guard — задача в ready", task_state(tid) == "ready", task_state(tid)) +check("3.3: TaskCompleted НЕ записан", "TaskCompleted" not in task_events(tid), task_events(tid)) +check("3.4: requeue-событие есть", "TaskPromoted" in task_events(tid)) + +# ── 4. Тот же тик: повторный complete с реальным результатом → done ── +es.append_event("task", tid, "TaskClaimed", {"level": "hour", "session_id": "t-guard2"}) +tid2, delivered = commands.complete_task_with_delivery( + tid, "Реальный результат: расчёт суммы бюджета проекта завершён", level="hour") +check("4.0: честный complete после requeue", task_state(tid) == "done", task_state(tid)) +check("4.1: TaskCompleted записан", task_events(tid).count("TaskCompleted") == 1) + +# ── 5. Честный «бюджет … исчерпан» в реальном результате → TaskCompleted ок ── +tid3, _sz3 = commands.submit_task(title="Тест честного", minutes=30, + details="тест: результат содержит бюджет-фразу по делу") +es.append_event("task", tid3, "TaskClaimed", {"level": "hour", "session_id": "t-guard3"}) +try: + commands.complete_task_with_delivery( + tid3, "Проверил кабинет: бюджет рекламной кампании исчерпан, отчёт готов", + level="hour") + check("5.0: честный результат проходит", task_state(tid3) == "done", task_state(tid3)) +except ValueError as e: + check("5.0: честный результат проходит", False, str(e)[:80]) + +print() +print("FAILS:", len(fails)) +sys.exit(1 if fails else 0) \ No newline at end of file