- portal_channel: команда cmd-settg-* (кнопка TG в UI портала) перехватывается
в pull() ДО инбокса (токен-секрет не попадает в стор/LLM), апплаер:
getMe-самотест -> атомарный .env (SIMBIOT_TG_TOKEN+ALLOWED, старый аллоулист
сохраняется) -> рестарт только simbiot-harness-tg; идемпотентно; отчёт в
чат портала (push, fallback outbox-файл); TgChannelConfigured в стор.
- isolated_runner: после TimeoutExpired kill процесс-группы воркера и ожидание
фактической смерти — без этого сабпроцессы продолжали писать BudgetSpent/
ToolCalled после SchedulerTimeout (burn 9router вхолостую, t-261002090242).
- levels: tick_timeout min 300->480 (занятый мин-уровень штатно поглощает такты;
эскалация e-261002085000). Контекст-схема не тронута (256k/512k/1M).
Тесты: test_budget_stub_guard / test_inbox_batch / test_delivery_idempotency —
зелёные; e2e на живом канале: cmd-settg consumed=1, стор чист, .env цел.
systemd-юниты не экспортируют .env; на Hetzner-агентах base_url тихо
уходил в дефолт 127.0.0.1:20128 (живой 9router только на эталонном
хосте). _env_or_file(): env > .env > default. E2E на агенте 82:
venv без openai (в cloud-init добавлен отдельно в coop).
- 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 по директиве прайса
1) per-call timeout LLM-вызова (SIMBIOT_LLM_CALL_TIMEOUT, 90с default,
max_retries=1): роутер-лог показал — зависший тик = POST в ollama
принят, но байт стрима не пришёл (DONE без TTFT), соседние вызовы
ходили за 1-3с. Один висящий вызов больше не убивает весь тик по
уровневому таймауту (300/2880с) — режется сам вызов, event-loop
завершает тик штатно, spend-хвост и задачи не теряются
(e-260930161900-baaecb, e-260930162600-cbb7e2, e-260930153143-85246b).
2) обрезка TG-инбокса (e-260930154125-082a2a): приём 4000→10000 симв.,
показ в контексте тика 300→1200 — длинные сообщения Alex больше
не теряют хвост.
3) loss-of-claim в батче: TimeoutExpired от isolated_runner пролетал
мимо TaskFailed-блока run_level_tick — claimed-задачи висели в
running до сирот-чистки (час+). Теперь claim сразу возвращается
TaskPromoted в очередь (reason с причиной смерти воркера).
Тесты 8/8 (hermetic), живой вызов модели с timeout=90 — ok.
Причина: find_task возвращал ОДНУ ready-задачу за такт — при 5+ ready
очередь росла быстрее исполнения (1 задача/такт).
Теперь:
- select_task_batch: ВСЕ ready-задачи класса уровня, старейшая первой;
- кап пачки MAX_TASK_BATCH (min=3, hour=6, day=6; env SIMBIOT_BATCH_MAX_<LEVEL>);
- бюджет контекста: секция задач <= ctx_tokens*3*0.5 символов,
мандат/выжимки/эскалации/инбокс остаются в тике; старейшая берётся всегда;
- run_level_tick: claim всего батча ДО воркера; TaskCompleted/TaskFailed
ПО каждой отдельно (смерть воркера не теряет батч в running);
- build_context: секция 'ТЕКУЩАЯ РАБОТА — БАТЧ из N' с постановками,
инструкция закрывать каждую задачу cli.py complete <id> -a;
- CLI complete: закрытие running-задачи с результатом
(commands.complete_task_with_delivery — доставка в TG идемпотентна).
Тесты: tests/test_task_batch.py — 28 проверок (ALL PASS), набор 8/8 зелёный.
Инцидент: Alex получал презентацию трижды (m-516173239/241). Две причины:
1) гонка push (тик) vs pull (delivery-демон): оба проходили проверку
delivery_status != 'sent', пока TG-отправка (до 35с) была в полёте;
2) ad-hoc curl sendDocument из мин-тиков вообще без метки delivered.
Фикс — delivery_ledger (INSERT OR IGNORE: факт доставки раз и навсегда)
+ delivery_locks (атомарный claim ДО отправки, TTL 120с; одно SQLite-
выражение = нет гонок между потоками и процессами). deliver_result /
deliver_artifact: ledger → claim → отправка → запись; сбой → release,
демон досыплет. Requeue сбрасывает ledger (доработка доставляется заново).
Новое: cli.py tg-deliver-file (идемпотентная отправка файлов, --force),
tg-notify -k <ключ>, tg_inbox.send_document (multipart на чистом urllib),
deliver_pending pre-filter по ledger, мандаты уровней: прямые
curl/api.telegram отправки запрещены. DeliverySkipped логирует пропуски.
Тесты: tests/test_delivery_idempotency.py — 49 проверок (гонка потоков,
межпроцессный claim, сбои+ретраи, requeue, CLI). Регрессия: 6/6 тестов ОК.
- InitiativeSent (aggregate cycle, id proactive:<slot>) — идемпотентно UNIQUE-стора
- es-проекция: последний слот + история sent (анти-спам стейт)
- harness/proactive.py: окно 5-21 UTC (история чата), интервал 8ч, тишина 2ч
после сообщения Alex, не при неотвеченных входящих/critical-эскалациях
- банк из 10 тем без LLM (ноль кредитов), round-robin по использованным
- хук в delivery-демоне (15с, best-effort); гейт важности не затронут
- CLI: proactive / --plan / --force; env-оверрайды интервала/тишины/слота
- tests/test_proactive.py: 34 проверки (окно/анти-спам/round-robin/идемпотентность/CLI)
Минутный тик отвечал по одному сообщению за тик (m-516173222..228 —
7 тиков подряд на один разговор): контекст показывал все, но мандат
говорил «ответь на него», и главное — нечем было закрыть несколько
входящих одним ответом (tg-reply закрывает одно, «хвосты» снова будили
гейт важности).
- tg_inbox.send_batch_reply: одна TG-отправка → ReplySent на КАЖДОЕ
входящее батча (полный текст у головного, у хвостовых — ссылка);
гонки (replied/ignored выпадают, пустой батч → ValueError), разные
чаты делят батч — каждому своя отправка
- cli tg-reply <id1>,<id2>,… -c: батч-режим; несколько id без -c — отказ
(защита от случайного закрытия); одиночный режим обратно совместим
- build_context: секция инбокса = «БАТЧ из N» с готовой командой
(все id через запятую) и инструкцией закрыть ВСЁ за этот тик; кап
10 → 50 (показываем весь батч, а не хвост)
- мандат min: батч-семантика (сводный ответ по смыслу разговора)
- тесты tests/test_inbox_batch.py: 32 проверки (hermetic), все зелёные
- гейт (e-260930062242/2510/2607/2741): collect_signals ранжирует события
(unreplied_inbox/ready_task/critical-эскалация = critical; warn-эскалации,
high_load = warn), gate_allows_llm решает по порогу уровня
(SIMBIOT_GATE_<LEVEL>, дефолты min=critical/hour=warn/day=warn);
тихий такт — GateSkipped + дайджест-заглушка, без LLM и без расхода роутера
- model в BudgetSpent/InvocationStarted (e-260930062120): видно, какая модель
и уровень тратили кредиты (strands не выносит фактическую модель в usage —
пишем запрошенный model_id)
- per-level лимиты тул-колов уже в дереве: resolve_tool_budget (min=30/hour=300,
env-оверрайд) (e-260930060717/060904)
- tests/test_gate.py: 28 hermetic-проверок, ALL PASS
- simbiot-presentation.html: презентация Simbiot (t-260930053538), 10 слайдов
Репродукция: мин-тик с реальным контекстом → модель (auto/low effort)
каждый ход делала tool-вызов, никогда не выдавая чистый текст;
limits={'turns': 15} обрывал цикл на границе хода, result.message
оставался toolResult-сообщением → str(result)='' → DigestWritten len 0.
limit_turns — нативный стоп SDK, но он не кладёт в историю итоговый
ассистентский текст. Наш end_turn (AfterToolsEvent) кладёт: дайджест
не пустеет. Защита от таймаутов остаётся двойной: cancel_tool на
вызовы сверх лимита + end_turn после партии.
E2E-подтверждение: тот же промпт, stop_reason=end_turn, текст 465
символов ('Тик чистый. Проверил: ...').
Симптом: DigestWritten min с пустым text при живом агенте (15 tool-вызовов,
BudgetSpent писались). Корень: PrintingCallbackHandler стримит в stdout
воркера; scheduler читает capture_output=True; длинный вывод (reasoning +
Tool #N на каждый вызов) переполнял pipe → BrokenPipeError внутри
stream_async → ok=False, текст тика потерян. Воркер перезапускался каждый
тик — падения шли серией, мин-дайджесты пустые.
Результат тика живёт в JSON-файле (result_path) — печать не нужна вовсе:
create_harness(callback_handler=null_callback_handler).
E2E: воркер с 2 shell-вызовами, stdout 0 байт, ok=True, текст на месте.
- harness/tg_commands.py: реестр команд, парсер (кириллица/транслит/@бот/без слэша),
хендлеры из read model, answer_pending (идемпотентно, cooldown 60с при лежащем TG),
setMyCommands с фолбэком кириллица→транслит (Bot API латиницу только)
- scheduler.py: сек-тик отвечает на команды без LLM (payload.commands),
build_context НЕ показывает команды мин-уровню (гонки/лежащий TG не тратят LLM)
- tg_inbox.py: send_message/send_reply принимают короткий timeout для рефлекса;
поллер при старте авторегистрирует команды бота
- cli.py: tg-commands --register/--list
- события TaskAwaitDelivery / DeliverySent: флаг 'результат доставить в чат'
на задаче, доставка идемпотентна (один раз sent — повторов нет)
- commands.mark_awaiting_delivery / deliver_result (единственный путь отправки) /
complete_task_with_delivery: завершение и отправка идут парой —
«сделал и промолчал» больше не случается
- scheduler: push после TaskCompleted + delivery-демон (pull каждые 15с —
досылает, если тик умер между завершением и отправкой); requeue сбрасывает
доставку — доработка снова уйдёт в чат
- orphan_requeue: running-задачи с мёртвым воркером (рестарт демона убивает
воркеров, TaskCompleted не пишется) возвращаются в очередь автоматически;
корень эскалации e-260929234911: 'повторный таймаут часового воркера'
был убийством живого воркера рестартом в 23:12/00:03, не таймаутом
(трейс и BudgetSpent шли вплоть до секунды рестарта)
- cli: task -a (подать с доставкой) + task-await (пометить существующую);
мин-уровень помечает задачи-просьбы из TG
- 18 тестов на копии БД: submit→await→complete→deliver→idempotency→
requeue→re-deliver→pull→orphan→rebuild
- voice.py: download по file_id (getFile→file, кэш ogg по file_unique_id),
два ASR-яруса: whisper-asr-webservice base (быстро ~1с) → fallback
faster-whisper large-v3-turbo (CPU int8, ~11с, точный на русском;
короткие voice ≤30с идут сразу в точный ярус — base коверкает фразы)
- _fw_asr_worker.py: faster-whisper в системном python3 (в venv нет),
subprocess JSON-обёртка (stdin JSON → stdout JSON)
- tg_inbox.py: file_unique_id в MessageReceived + расшифровка сразу
при приёме (поллер пишет MessageTranscribed до первого мин-тика;
сбой транскрипции не валит поллер) + video_note как транскрибируемый kind
- es.py: проекция MessageTranscribed (transcript→text, original_text,
reset для --force), cli tg-transcribe <id> [--force] — ручная расшифровка
- scheduler.py: голосовые в контексте мин-уровня даже с пустым текстом
(неотвеченное не исчезает): пометки «расшифровано/не расшифровано/
расшифровка не удалась», сортировка по ts, срез 10
Е2Е проверено: m-516173163 — кэш-хит → turbo → «Ты теперь слышишь мои
голосовые, да?» в стор; base на том же файле давал «Туше, что ты тебя
слышишь» — выбор ярусов подтверждён фактом. Воркер past-hour умер по
2880с-таймауту до коммита; WIP доведён, протестирован, закоммичен.
busy в read model всегда False — истинная занятость живёт в памяти
планировщика, событий для неё нет. Срез показывал «ожидает», пока
часовой уровень держал running-задачу. TaskClaimed закрепляет уровень
за задачей в проекции — теперь срез показывает «занят (running-задача)».
- tg_inbox.notify_chat(): отправка в чат харнеса без входящего-родителя
(чат = последний MessageReceived от Alex)
- tg_inbox.ladder_snapshot(): срез лестницы — занятость/тики/ошибки уровней,
готово/в работе, открытые эскалации; read-only через run_projections()
- scheduler._notify_busy(): уровень сообщает в TG о взятии задачи
(анти-спам 15м на уровень) — TaskClaimed в scheduler.run_level_tick
- cli.py: команда ladder + tg-notify; /лестница|/ladder|/статус|/status
в tg-reply заменяется на срез лестницы — запрос из TG самодостаточен
- guard в __main__ tg_inbox: единственный легитимный поллер — systemd-сервис
(раньше python -m harness.tg_inbox с любыми аргументами поднимал
конкурирующего поллера → 409 Conflict в легитимном)
Два корня пустого spend за час:
1) usage-добыча в воркере смотрела m["message"]["usage"] — у strands usage живёт
в message["metadata"]["usage"] (event_loop.py), поэтому usage всегда был пуст
(тот же баг, что уже чинили в runtime._extract_usage).
2) BudgetSpent писал scheduler ПОСЛЕ успешного возврата воркера — таймаут убивал
воркер раньше записи, spend терял весь тик.
3) Модель: strands_harness строит OpenAIResponsesModel, у 9router /responses — шим
без usage (проверено: response.completed приходит без usage; non-stream возвращает
chat-completion-объект). Перевёл на OpenAIModel (chat/completions, include_usage) —
usage приходит и с инструментами, и в reasoning-режиме.
Фикс:
- воркер пишет BudgetSpent ИНКРЕМЕНТАЛЬНО после каждого модельного вызова
(hook на MessageAddedEvent, assistant с metadata.usage, дедуп по объекту) —
таймаут теряет максимум хвост текущего вызова, не весь тик;
- _extract_usage: корректная структура + суммирование по шагам (model_calls);
- scheduler дублирует запись только если воркер не смог разобрать usage;
- события помечены source: worker|runner и trace_id тика.
Проверено: дельты воркера == итоговый usage тика (39-шаговый тик сходится);
убитый по таймауту тик сохраняет все завершённые шаги; spend виден в
read model/dashboard.
- harness/dashboard.py: stdlib-only HTTP-сервер, /api/state (read-only из event store через run_projections — та же проекция, что у scheduler/CLI), /api/health на отдельном порту 8766 (без auth — для systemd healthcheck)
- harness/dashboard.html: страница в стиле architecture.html (paper/ink/signal), fetch каждые 5с, честный errbox при недоступности API, XSS-экранирование всех полей
- auth: SIMBIOT_DASH_TOKEN из /etc/simbiot/dashboard.env (env-файл юнита, 600); без/с неверным токеном — 401; токен в query → cookie
- systemd-юнит simbiot-dashboard.service (после scheduler, restart=always), порт 8765/tcp в UFW