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:
Simbiot Harness 2026-10-01 09:11:23 +00:00
parent 11a8ccc279
commit a0179e5df7
7 changed files with 270 additions and 6 deletions

View file

@ -207,6 +207,9 @@ e2e: 20 отмен подряд), кладя в историю ассистен
Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов +
эскалации + свежие дайджесты, автообновление каждые 5с (JSON-API `/api/state`).
Running-задача подсвечена бейджем «🔧 в работе: <уровень> · <elapsed>»
(t-261001082111): время взятия — `claimed_at` из проекции TaskClaimed,
таймер живёт в браузере; requeue сбрасывает `claimed_at`.
```bash
systemctl status simbiot-dashboard # порт 8765, healthcheck 8766

View 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 &amp; 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).

View file

@ -56,6 +56,8 @@
.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.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)}
.pill.sev-critical{background:var(--err);border-color:var(--err);color:#fff}
.pill.sev-warn{background:#fdeedd;border-color:var(--warn);color:var(--warn)}
@ -115,6 +117,24 @@ const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt
const $ = id => document.getElementById(id);
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 v = parseInt(new URLSearchParams(location.search).get("refresh") || "", 10);
return v > 0 && v <= 120 ? v : 5;
@ -143,6 +163,7 @@ function taskDetail(t){
["id", t.id],
["размер / статус", `${t.size} · ${t.status}${t.level ? " · уровень " + t.level : ""}`],
["создана", fmtTime(t.created)],
t.status === "running" && t.claimed_at ? ["взята в работу", fmtTime(t.claimed_at) + " · " + elapsedStr(elapsedSec(t.claimed_at)) + " назад"] : null,
["завершена", t.finished ? fmtTime(t.finished) : "—"],
["session", t.session_id || "—"],
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><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>${pill(t.status)}</td>
<td>${pill(t.status)}${workingTag(t)}</td>
<td class="m">${esc(t.level || "—")}</td>
<td class="m dim">${fmtTime(t.created)}</td>
</tr>

View file

@ -69,6 +69,9 @@ def build_state() -> dict:
"details": (t.get("details") or "")[:2000],
"promotions": t.get("promotions"), "promoted_to": t.get("promoted_to"),
"session_id": t.get("session_id"),
# t-261001082111: время взятия в работу (TaskClaimed) — для
# пометки «в работе» и таймера elapsed на карточке running
"claimed_at": t.get("claimed_at"),
"report_level": report.get("level"),
"report_ts": report.get("ts"),
"report": (report.get("text") or "")[:1500],

View file

@ -298,7 +298,8 @@ def _apply_event(model: dict, ev: dict):
elif etype == "TaskClaimed":
t = model["tasks"].get(aid)
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":
t = model["tasks"].get(aid)
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"]:
t.update(size=data["to"])
t.update(status="ready", level=None, session_id=None,
claimed_at=None,
promoted_to=data.get("to"), promotions=(t.get("promotions") or 0) + 1)
# requeue доработки (from==to): старая доставка не считается —
# новый результат должен уйти в чат заново (t-260930083937).

View file

@ -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):
"""Один тик уровня с LLM: дайджест + (опц.) БАТЧ задач. Возвращает (text, [task_id]).
@ -471,11 +496,9 @@ def run_level_tick(level_cfg, seq: int, model: dict):
# GUARD (e-261001074527-f0c1b4): финальный текст тика «бюджет исчерпан» —
# стоп-сообщение воркера, а не результат; ночь 20727 им фейково закрылись
# 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):
for tid in (claimed or []):
_requeue(tid, reason="guard: финал тика «бюджет исчерпан» — работа не завершена, задача в ready (e-261001074527)")
return text, []
return _guard_budget_stub_requeue(claimed, text)
for tid in (claimed or []):
es.append_event("task", tid, "TaskCompleted", {
"result_ref": trace_id, "answer": (text or "")[:2000],

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