fix(guard)+feat(dash): requeue ready-гонка не валила тик (e-261001083543-0eab6d); дашборд «🔧 в работе» + elapsed (t-261001082111); пакет покупателя предпродажи (t-261001085722)
- scheduler: guard «бюджет исчерпан» вынесен в _guard_budget_stub_requeue()
— ValueError requeue_task при ready (задача уже возвращена task_promoted
путём того же тика) гасится, тик дофинализируется; тесты
tests/test_guard_ready_race.py (FAILS 0), test_budget_stub_guard зелёный;
- es: TaskClaimed → task.claimed_at; TaskPromoted(requeue) → claimed_at=None;
- dashboard.py/.html: API отдаёт claimed_at; бейдж «🔧 в работе: hour · 12м»
(минуты/секунды/часы), подсветка running-строки, «взята в работу» в деталях;
- docs/PACKAGE-SIMBIOT-PRESALE.md: состав пакета подписки (EN-блок для
лендинга, состояние компонентов), цена единая $99.9 по директиве прайса
This commit is contained in:
parent
11a8ccc279
commit
a0179e5df7
7 changed files with 270 additions and 6 deletions
|
|
@ -207,6 +207,9 @@ e2e: 20 отмен подряд), кладя в историю ассистен
|
||||||
|
|
||||||
Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов +
|
Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов +
|
||||||
эскалации + свежие дайджесты, автообновление каждые 5с (JSON-API `/api/state`).
|
эскалации + свежие дайджесты, автообновление каждые 5с (JSON-API `/api/state`).
|
||||||
|
Running-задача подсвечена бейджем «🔧 в работе: <уровень> · <elapsed>»
|
||||||
|
(t-261001082111): время взятия — `claimed_at` из проекции TaskClaimed,
|
||||||
|
таймер живёт в браузере; requeue сбрасывает `claimed_at`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status simbiot-dashboard # порт 8765, healthcheck 8766
|
systemctl status simbiot-dashboard # порт 8765, healthcheck 8766
|
||||||
|
|
|
||||||
94
docs/PACKAGE-SIMBIOT-PRESALE.md
Normal file
94
docs/PACKAGE-SIMBIOT-PRESALE.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Simbiot Presale — пакет покупателя (что входит)
|
||||||
|
|
||||||
|
> Задача t-261001085722 [M]. Источник — Alex (m-516173330/331): «то, что мы
|
||||||
|
> даём ребятам, которые купят у нас харнес, вместе со всеми остальными
|
||||||
|
> плюшками». Согласовано с директивой прайса (docs/PRICE-DIRECTIVE-SIMBIOT.md)
|
||||||
|
> и лендингом предпродажи (docs/simbiot_presale_landing.html, задача
|
||||||
|
> t-261001075225).
|
||||||
|
|
||||||
|
## Главный принцип
|
||||||
|
|
||||||
|
**Покупатель получает тот же харнес, что работает у нас, — без урезанной
|
||||||
|
версии.** Полный ритм, полные плюшки подписки, все обновления, пока подписка
|
||||||
|
активна. Мы продаём свою собственную рабочую систему, а не её демо или clone.
|
||||||
|
|
||||||
|
## Пакет подписки — $99.9/мес (единая цена по всей странице)
|
||||||
|
|
||||||
|
1. **Свой (dedicated) харнес Simbiot** — ритмичный агент организации:
|
||||||
|
лестница уровней (секунды → минуты → часы → дни), задачи, дайджесты,
|
||||||
|
эскалации, исполнение инструментами (shell/read/write/edit + поиск по
|
||||||
|
базе знаний). Не «чатбот»: работает по тактам сам, докладывает по графику,
|
||||||
|
поднимает наверх то, что не может решить безопасно.
|
||||||
|
2. **Портал кооператива** — общий контур организации: доска задач и проектов,
|
||||||
|
базы знаний с семантическим поиском (RAG), протоколы встреч и решений,
|
||||||
|
REST API + MCP-шлюз с доступом по токенам агентов.
|
||||||
|
3. **Развёртывание агентов** — новые агенты разворачиваются на портале как
|
||||||
|
первоклассные участники: профиль, доступы, свой ритм. Поднимаем под вашу
|
||||||
|
команду при онбординге (delivery — до конца месяца с покупки).
|
||||||
|
4. **Дашборд задач** — живая картина работы: статусы/размеры задач, пометка
|
||||||
|
«🔧 в работе» с таймером, лестница уровней, эскалации, дайджесты, расходы.
|
||||||
|
Один взгляд — и видно, чем занят харнес.
|
||||||
|
5. **Чат с агентом** — переписка со своим агентом напрямую (Telegram-подобный
|
||||||
|
чат через портал/шлюз): задача обычным сообщением, ответ приходит сам,
|
||||||
|
есть батч-ответы и голосовые.
|
||||||
|
6. **Подписки на события** — агенты и дашборд подписываются на события
|
||||||
|
системы: завершение задач, эскалации, дайджест-ритм. Агент реагирует сам,
|
||||||
|
без опроса вручную.
|
||||||
|
7. **Обновления** — харнес развивается в нашем проде, покупатель получает
|
||||||
|
всё то же самое: новые инструменты, надёжность, интеграции. Разрыв между
|
||||||
|
«у нас» и «у вас» не образуется — это та же установка.
|
||||||
|
|
||||||
|
**LLM-бэкенд**: модели — через наш роутер (тот же, что у нас в проде);
|
||||||
|
базовые квоты включены в подписку, детали и потолки фиксируются при
|
||||||
|
онбординге. Свои API-ключи подключаются, если команда хочет свой биллинг.
|
||||||
|
|
||||||
|
## Условия предпродажи
|
||||||
|
|
||||||
|
- **Цена**: $99.9/мес — единое число по всей странице, без скидочных форм
|
||||||
|
и «было $100» (директива прайса: лендинг тестирует ценность, демпинг ломает тест).
|
||||||
|
- **Мест**: 10 финальных (срочность — только через лимит мест, не через скидки).
|
||||||
|
- **Setup & onboarding**: разворачиваем своей командой на ваших каналах,
|
||||||
|
деливери — до конца месяца.
|
||||||
|
- **Priority support**: на всё время предпродажи.
|
||||||
|
- Подписочная модель: оплата ежемесячно, доступ сохраняется, пока подписка активна.
|
||||||
|
|
||||||
|
## Что НЕ входит (честные границы)
|
||||||
|
|
||||||
|
- Кастомная разработка под заказ (отдельные проекты/контракт — обсуждается
|
||||||
|
отдельно, это не подписка).
|
||||||
|
- Персональные SLA-гарантии времени ответа (есть priority support, но без
|
||||||
|
24/7-дежурства на старте).
|
||||||
|
- Ваши расходы на внешние сервисы (Stripe, свои API-ключи — по вашему биллингу).
|
||||||
|
|
||||||
|
## EN-блок для лендинга (offer → «What's included»)
|
||||||
|
|
||||||
|
Готовый список для оффер-блока лендинга (укладывается в директиву прайса —
|
||||||
|
упоминаний другой цены нет):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<li>Your own Simbiot harness — the same system we run our coop on</li>
|
||||||
|
<li>Cooperative portal: shared task board, knowledge base with semantic
|
||||||
|
search, MCP & REST API access</li>
|
||||||
|
<li>Agent deployment: new agents onboarded as first-class members of your org</li>
|
||||||
|
<li>Live task dashboard: statuses, escalations, digests — one glance</li>
|
||||||
|
<li>Chat with your agent: message it directly, answers come on their own</li>
|
||||||
|
<li>Event subscriptions: your agents react to what happens, proactively</li>
|
||||||
|
<li>Ongoing updates — your harness improves while you're subscribed</li>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Состояние компонентов (честно, для внутренней сверки)
|
||||||
|
|
||||||
|
| Компонент | Статус на 2026-10-01 |
|
||||||
|
|---|---|
|
||||||
|
| Харнес (лестница, задачи, эскалации, дайджесты) | в проде (наш продакшен) |
|
||||||
|
| Портал: задачи/знания/RAG/решения | в проде (cil.by) |
|
||||||
|
| MCP-шлюз + REST API портала | в проде (X-Agent-Token) |
|
||||||
|
| Развёртывание харнесов для новых агентов | в работе (t-260930110622) |
|
||||||
|
| Дашборд задач (+ «в работе»-бейдж) | в проде (t-261001082111 сегодня закрыта) |
|
||||||
|
| Чат с агентом через харнес/gateway | в работе (t-261001084819) |
|
||||||
|
| Подписки на события | частично в проде (notify-механика харнеса) |
|
||||||
|
| Предпродажный лендинг | черновик готов, закрытие лендинг-задачи t-261001075225 |
|
||||||
|
|
||||||
|
Всё «в работе» входит в дорожную карту подписки и доставляется её темпом —
|
||||||
|
это те же задачи из нашего собственного бэклога, приоритет им даёт цель
|
||||||
|
AUM (README: t-260929161138).
|
||||||
|
|
@ -56,6 +56,8 @@
|
||||||
.pill.running{background:var(--ink);color:var(--paper)}
|
.pill.running{background:var(--ink);color:var(--paper)}
|
||||||
.pill.failed{background:#fdeaec;border-color:var(--err);color:var(--err)}
|
.pill.failed{background:#fdeaec;border-color:var(--err);color:var(--err)}
|
||||||
.pill.done{background:var(--card);color:var(--dim)}
|
.pill.done{background:var(--card);color:var(--dim)}
|
||||||
|
.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)}
|
.sizeS{color:var(--ok)} .sizeM{color:var(--warn)} .sizeL{color:var(--signal)} .sizeXL{color:var(--err)}
|
||||||
.pill.sev-critical{background:var(--err);border-color:var(--err);color:#fff}
|
.pill.sev-critical{background:var(--err);border-color:var(--err);color:#fff}
|
||||||
.pill.sev-warn{background:#fdeedd;border-color:var(--warn);color:var(--warn)}
|
.pill.sev-warn{background:#fdeedd;border-color:var(--warn);color:var(--warn)}
|
||||||
|
|
@ -115,6 +117,24 @@ const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
const fmtTime = s => (s || "").replace("T", " ").slice(5, 19);
|
const fmtTime = s => (s || "").replace("T", " ").slice(5, 19);
|
||||||
|
|
||||||
|
// t-261001082111: таймер elapsed по running-задаче — «сейчас в работе».
|
||||||
|
// Claim-время приходит из API (TaskClaimed в проекции), «сейчас» — часы
|
||||||
|
// браузера (обновление каждые 5с живое, серверная generated_at не нужна).
|
||||||
|
const elapsedSec = claimed => {
|
||||||
|
const t0 = claimed ? Date.parse(claimed) : NaN;
|
||||||
|
return Number.isFinite(t0) ? Math.max(0, Math.floor((Date.now() - t0) / 1000)) : null;
|
||||||
|
};
|
||||||
|
const elapsedStr = s => {
|
||||||
|
if (s == null) return "—";
|
||||||
|
if (s < 60) return s + "с";
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||||
|
return (h ? h + "ч " : "") + (h && !m && s % 60 < 30 ? "" : m + "м");
|
||||||
|
};
|
||||||
|
const workingTag = t => {
|
||||||
|
if (t.status !== "running") return "";
|
||||||
|
return ` <span class="pill working" title="взята в работу: ${esc(t.claimed_at || "—")}">🔧 в работе: ${esc(t.level || "?")} · ${esc(elapsedStr(elapsedSec(t.claimed_at)))}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
const REFRESH = (() => {
|
const REFRESH = (() => {
|
||||||
const v = parseInt(new URLSearchParams(location.search).get("refresh") || "", 10);
|
const v = parseInt(new URLSearchParams(location.search).get("refresh") || "", 10);
|
||||||
return v > 0 && v <= 120 ? v : 5;
|
return v > 0 && v <= 120 ? v : 5;
|
||||||
|
|
@ -143,6 +163,7 @@ function taskDetail(t){
|
||||||
["id", t.id],
|
["id", t.id],
|
||||||
["размер / статус", `${t.size} · ${t.status}${t.level ? " · уровень " + t.level : ""}`],
|
["размер / статус", `${t.size} · ${t.status}${t.level ? " · уровень " + t.level : ""}`],
|
||||||
["создана", fmtTime(t.created)],
|
["создана", fmtTime(t.created)],
|
||||||
|
t.status === "running" && t.claimed_at ? ["взята в работу", fmtTime(t.claimed_at) + " · " + elapsedStr(elapsedSec(t.claimed_at)) + " назад"] : null,
|
||||||
["завершена", t.finished ? fmtTime(t.finished) : "—"],
|
["завершена", t.finished ? fmtTime(t.finished) : "—"],
|
||||||
["session", t.session_id || "—"],
|
["session", t.session_id || "—"],
|
||||||
t.promoted_to ? ["промоушен", `${t.promotions || 1}× → ${t.promoted_to}`] : null,
|
t.promoted_to ? ["промоушен", `${t.promotions || 1}× → ${t.promoted_to}`] : null,
|
||||||
|
|
@ -210,7 +231,7 @@ function render(s){
|
||||||
<td class="m dim">${esc(t.id)}</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>${t.error ? `<br><span class="m" style="color:var(--err)">${esc(String(t.error).slice(0,140))}</span>` : ""}</td>
|
||||||
<td>${size(t.size)}</td>
|
<td>${size(t.size)}</td>
|
||||||
<td>${pill(t.status)}</td>
|
<td>${pill(t.status)}${workingTag(t)}</td>
|
||||||
<td class="m">${esc(t.level || "—")}</td>
|
<td class="m">${esc(t.level || "—")}</td>
|
||||||
<td class="m dim">${fmtTime(t.created)}</td>
|
<td class="m dim">${fmtTime(t.created)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ def build_state() -> dict:
|
||||||
"details": (t.get("details") or "")[:2000],
|
"details": (t.get("details") or "")[:2000],
|
||||||
"promotions": t.get("promotions"), "promoted_to": t.get("promoted_to"),
|
"promotions": t.get("promotions"), "promoted_to": t.get("promoted_to"),
|
||||||
"session_id": t.get("session_id"),
|
"session_id": t.get("session_id"),
|
||||||
|
# t-261001082111: время взятия в работу (TaskClaimed) — для
|
||||||
|
# пометки «в работе» и таймера elapsed на карточке running
|
||||||
|
"claimed_at": t.get("claimed_at"),
|
||||||
"report_level": report.get("level"),
|
"report_level": report.get("level"),
|
||||||
"report_ts": report.get("ts"),
|
"report_ts": report.get("ts"),
|
||||||
"report": (report.get("text") or "")[:1500],
|
"report": (report.get("text") or "")[:1500],
|
||||||
|
|
|
||||||
|
|
@ -298,7 +298,8 @@ def _apply_event(model: dict, ev: dict):
|
||||||
elif etype == "TaskClaimed":
|
elif etype == "TaskClaimed":
|
||||||
t = model["tasks"].get(aid)
|
t = model["tasks"].get(aid)
|
||||||
if t:
|
if t:
|
||||||
t.update(status="running", level=data.get("level"), session_id=data.get("session_id"))
|
t.update(status="running", level=data.get("level"), session_id=data.get("session_id"),
|
||||||
|
claimed_at=ev["timestamp"])
|
||||||
elif etype == "TaskCompleted":
|
elif etype == "TaskCompleted":
|
||||||
t = model["tasks"].get(aid)
|
t = model["tasks"].get(aid)
|
||||||
if t:
|
if t:
|
||||||
|
|
@ -313,6 +314,7 @@ def _apply_event(model: dict, ev: dict):
|
||||||
if data.get("from") and data.get("to") and data["from"] != data["to"]:
|
if data.get("from") and data.get("to") and data["from"] != data["to"]:
|
||||||
t.update(size=data["to"])
|
t.update(size=data["to"])
|
||||||
t.update(status="ready", level=None, session_id=None,
|
t.update(status="ready", level=None, session_id=None,
|
||||||
|
claimed_at=None,
|
||||||
promoted_to=data.get("to"), promotions=(t.get("promotions") or 0) + 1)
|
promoted_to=data.get("to"), promotions=(t.get("promotions") or 0) + 1)
|
||||||
# requeue доработки (from==to): старая доставка не считается —
|
# requeue доработки (from==to): старая доставка не считается —
|
||||||
# новый результат должен уйти в чат заново (t-260930083937).
|
# новый результат должен уйти в чат заново (t-260930083937).
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,31 @@ def _notify_busy(level_cfg, task):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_budget_stub_requeue(claimed, text: str):
|
||||||
|
"""GUARD (e-261001074527-f0c1b4): финальный текст тика — отписка воркера
|
||||||
|
«бюджет исчерпан» (стоп-сообщение, не результат) → задачи пачки без
|
||||||
|
TaskCompleted возвращаются в ready.
|
||||||
|
|
||||||
|
e-261001083543-0eab6d: между claim и guard-фазой задача может УЖЕ быть
|
||||||
|
в ready (task_promoted вернул claim в очередь сразу после краха воркера —
|
||||||
|
тот же тик), Тогда requeue_task кидает ValueError «requeue не нужен» и
|
||||||
|
валил весь тик: дайджест/спенд/завершение других задач пачки терялись.
|
||||||
|
Фикс: requeue для ready невозможен по определению — тихо пропускаем.
|
||||||
|
TaskCompleted здесь не пишется никогда (это не результат работы).
|
||||||
|
"""
|
||||||
|
if not claimed:
|
||||||
|
return text, []
|
||||||
|
from .commands import requeue_task as _requeue
|
||||||
|
reason = ("guard: финал тика «бюджет исчерпан» — работа не завершена, "
|
||||||
|
"задача в ready (e-261001074527)")
|
||||||
|
for tid in (claimed or []):
|
||||||
|
try:
|
||||||
|
_requeue(tid, reason=reason)
|
||||||
|
except ValueError as e:
|
||||||
|
_log(f"guard: requeue {tid} пропущен ({str(e)[:80]}) — уже в ready")
|
||||||
|
return text, []
|
||||||
|
|
||||||
|
|
||||||
def run_level_tick(level_cfg, seq: int, model: dict):
|
def run_level_tick(level_cfg, seq: int, model: dict):
|
||||||
"""Один тик уровня с LLM: дайджест + (опц.) БАТЧ задач. Возвращает (text, [task_id]).
|
"""Один тик уровня с LLM: дайджест + (опц.) БАТЧ задач. Возвращает (text, [task_id]).
|
||||||
|
|
||||||
|
|
@ -471,11 +496,9 @@ def run_level_tick(level_cfg, seq: int, model: dict):
|
||||||
# GUARD (e-261001074527-f0c1b4): финальный текст тика «бюджет исчерпан» —
|
# GUARD (e-261001074527-f0c1b4): финальный текст тика «бюджет исчерпан» —
|
||||||
# стоп-сообщение воркера, а не результат; ночь 20727 им фейково закрылись
|
# стоп-сообщение воркера, а не результат; ночь 20727 им фейково закрылись
|
||||||
# 5 L-задач. Такой тик работы не завершал — задачи возвращаются в ready.
|
# 5 L-задач. Такой тик работы не завершал — задачи возвращаются в ready.
|
||||||
from .commands import looks_like_budget_stub, requeue_task as _requeue
|
from .commands import looks_like_budget_stub
|
||||||
if (text or "") and looks_like_budget_stub(text):
|
if (text or "") and looks_like_budget_stub(text):
|
||||||
for tid in (claimed or []):
|
return _guard_budget_stub_requeue(claimed, text)
|
||||||
_requeue(tid, reason="guard: финал тика «бюджет исчерпан» — работа не завершена, задача в ready (e-261001074527)")
|
|
||||||
return text, []
|
|
||||||
for tid in (claimed or []):
|
for tid in (claimed or []):
|
||||||
es.append_event("task", tid, "TaskCompleted", {
|
es.append_event("task", tid, "TaskCompleted", {
|
||||||
"result_ref": trace_id, "answer": (text or "")[:2000],
|
"result_ref": trace_id, "answer": (text or "")[:2000],
|
||||||
|
|
|
||||||
118
tests/test_guard_ready_race.py
Normal file
118
tests/test_guard_ready_race.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Guard requeue при ready-статусе — регресс e-261001083543-0eab6d.
|
||||||
|
|
||||||
|
Сценарий падения min-тика 09:00: claim → воркер умер → exception-путь
|
||||||
|
написал TaskPromoted (задача уже ready) → guard-фаза «бюджет исчерпан»
|
||||||
|
вызвала requeue_task → ValueError «requeue не нужен» → тик упал,
|
||||||
|
дайджест/спенд/доставed остальных задач пачки потеряны.
|
||||||
|
|
||||||
|
Проверяет: _guard_budget_stub_requeue со смешанной пачкой
|
||||||
|
(failed / ready / done) НЕ бросает исключений, failed уходит в ready,
|
||||||
|
TaskCompleted не пишется, тик продолжает финализацию.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_DST = "/tmp/simbiot-test-guard-ready.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
|
||||||
|
from harness.scheduler import _guard_budget_stub_requeue
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
stub = "тик завершён: бюджет шагов исчерпан (30 tool-вызовов)."
|
||||||
|
|
||||||
|
# Пачка: три задачи в РАЗНЫХ статусах — как в реальном сбойном тике:
|
||||||
|
# failed — orphan-обработчик успел TaskFailed;
|
||||||
|
# ready — exception-путь уже вернул claim в очередь (TaskPromoted);
|
||||||
|
# running — обычный случай, guard должен вернуть в ready.
|
||||||
|
tid_failed, _ = commands.submit_task(title="пачка: failed", minutes=5, details="")
|
||||||
|
es.append_event("task", tid_failed, "TaskClaimed", {"level": "min", "session_id": "s-a"})
|
||||||
|
es.append_event("task", tid_failed, "TaskFailed", {"error": "симуляция: воркер умер после claim"})
|
||||||
|
es.run_projections()
|
||||||
|
check("0.1: подготовка failed", task_state(tid_failed) == "failed", task_state(tid_failed))
|
||||||
|
|
||||||
|
tid_ready, _ = commands.submit_task(title="пачка: ready", minutes=5, details="")
|
||||||
|
es.append_event("task", tid_ready, "TaskClaimed", {"level": "min", "session_id": "s-a"})
|
||||||
|
es.run_projections()
|
||||||
|
es.append_event("task", tid_ready, "TaskPromoted", {"to": "S", "from": "S",
|
||||||
|
"reason": "симуляция: exception-путь вернул claim в очередь"})
|
||||||
|
es.run_projections()
|
||||||
|
check("0.2: подготовка ready", task_state(tid_ready) == "ready", task_state(tid_ready))
|
||||||
|
|
||||||
|
tid_running, _ = commands.submit_task(title="пачка: running", minutes=5, details="")
|
||||||
|
es.append_event("task", tid_running, "TaskClaimed", {"level": "min", "session_id": "s-a"})
|
||||||
|
es.run_projections()
|
||||||
|
check("0.3: подготовка running", task_state(tid_running) == "running", task_state(tid_running))
|
||||||
|
|
||||||
|
batch = [tid_failed, tid_ready, tid_running]
|
||||||
|
|
||||||
|
# ── 1. Главный регресс: guard на смешанной пачке НЕ бросает ──
|
||||||
|
out_text, out_ids = _guard_budget_stub_requeue(batch, stub)
|
||||||
|
check("1.0: нет исключения на ready-члене пачки", out_text == stub and out_ids == [])
|
||||||
|
check("1.1: failed → ready", task_state(tid_failed) == "ready", task_state(tid_failed))
|
||||||
|
check("1.2: running → ready", task_state(tid_running) == "ready", task_state(tid_running))
|
||||||
|
check("1.3: ready остаётся ready (без второго TaskPromoted от guard)",
|
||||||
|
task_events(tid_ready).count("TaskPromoted") == 1, task_events(tid_ready))
|
||||||
|
check("1.4: TaskCompleted не пишется guard-ом",
|
||||||
|
all("TaskCompleted" not in task_events(t) for t in batch))
|
||||||
|
|
||||||
|
# ── 2. Идемпотентность: повторный guard по той же пачке тоже без исключений ──
|
||||||
|
_guard_budget_stub_requeue(batch, stub)
|
||||||
|
check("2.0: повторный guard не валился", task_state(tid_running) == "ready")
|
||||||
|
|
||||||
|
# ── 3. Пустой claim → no-op ──
|
||||||
|
t2, i2 = _guard_budget_stub_requeue([], stub)
|
||||||
|
check("3.0: пустая пачка — no-op", t2 == stub and i2 == [])
|
||||||
|
|
||||||
|
# ── 4. Сценарий end-to-end уровня: run_level_tick не падает на guard-фазе ──
|
||||||
|
# Готовим пачку с ready-членом и напрямую вызываем guard-путь run_level_tick
|
||||||
|
# через monkeypatch: executor подменяем на стаб-воркера со стаб-стоп-фразой.
|
||||||
|
tid_e2e, _ = commands.submit_task(title="e2e: guard не валил тик", minutes=5, details="")
|
||||||
|
es.append_event("task", tid_e2e, "TaskClaimed", {"level": "min", "session_id": "s-e2e"})
|
||||||
|
es.append_event("task", tid_e2e, "TaskPromoted", {"to": "S", "from": "S",
|
||||||
|
"reason": "симуляция: task_promoted в том же тике"})
|
||||||
|
es.run_projections()
|
||||||
|
check("4.0: e2e задача в ready", task_state(tid_e2e) == "ready")
|
||||||
|
|
||||||
|
# run_level_tick вызывает executor внутри — guard-путь тестируем напрямую (§1),
|
||||||
|
# здесь проверяем лишь тестируемость точки вызова
|
||||||
|
import harness.scheduler as sch
|
||||||
|
import inspect
|
||||||
|
src = inspect.getsource(sch.run_level_tick)
|
||||||
|
check("4.1: guard в run_level_tick идёт через функцию (тестируемо)",
|
||||||
|
"_guard_budget_stub_requeue" in src)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("FAILS:", len(fails))
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
Loading…
Reference in a new issue