diff --git a/README.md b/README.md index 4d58764..2353020 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,9 @@ e2e: 20 отмен подряд), кладя в историю ассистен Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов + эскалации + свежие дайджесты, автообновление каждые 5с (JSON-API `/api/state`). +Running-задача подсвечена бейджем «🔧 в работе: <уровень> · » +(t-261001082111): время взятия — `claimed_at` из проекции TaskClaimed, +таймер живёт в браузере; requeue сбрасывает `claimed_at`. ```bash systemctl status simbiot-dashboard # порт 8765, healthcheck 8766 diff --git a/docs/PACKAGE-SIMBIOT-PRESALE.md b/docs/PACKAGE-SIMBIOT-PRESALE.md new file mode 100644 index 0000000..c7c10b0 --- /dev/null +++ b/docs/PACKAGE-SIMBIOT-PRESALE.md @@ -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 +
  • Your own Simbiot harness — the same system we run our coop on
  • +
  • Cooperative portal: shared task board, knowledge base with semantic + search, MCP & REST API access
  • +
  • Agent deployment: new agents onboarded as first-class members of your org
  • +
  • Live task dashboard: statuses, escalations, digests — one glance
  • +
  • Chat with your agent: message it directly, answers come on their own
  • +
  • Event subscriptions: your agents react to what happens, proactively
  • +
  • Ongoing updates — your harness improves while you're subscribed
  • +``` + +## Состояние компонентов (честно, для внутренней сверки) + +| Компонент | Статус на 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). \ No newline at end of file diff --git a/harness/dashboard.html b/harness/dashboard.html index 6c8f905..34e8264 100644 --- a/harness/dashboard.html +++ b/harness/dashboard.html @@ -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 => ({"&":"&","<":"< 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 ` 🔧 в работе: ${esc(t.level || "?")} · ${esc(elapsedStr(elapsedSec(t.claimed_at)))}`; +}; + 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){ ${esc(t.id)} ${esc(t.title)}${t.error ? `
    ${esc(String(t.error).slice(0,140))}` : ""} ${size(t.size)} - ${pill(t.status)} + ${pill(t.status)}${workingTag(t)} ${esc(t.level || "—")} ${fmtTime(t.created)} diff --git a/harness/dashboard.py b/harness/dashboard.py index 2e2fe95..4cbf7bc 100644 --- a/harness/dashboard.py +++ b/harness/dashboard.py @@ -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], diff --git a/harness/es.py b/harness/es.py index 94be524..d076048 100644 --- a/harness/es.py +++ b/harness/es.py @@ -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). diff --git a/harness/scheduler.py b/harness/scheduler.py index dbdf161..67b92e2 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -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], diff --git a/tests/test_guard_ready_race.py b/tests/test_guard_ready_race.py new file mode 100644 index 0000000..acd98da --- /dev/null +++ b/tests/test_guard_ready_race.py @@ -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) \ No newline at end of file