Compare commits

..

15 commits

Author SHA1 Message Date
Simbiot Harness
b2391c2b5b set-tg-bot: 3 попытки getMe timeout=15 + backoff 3/6с (t-261016141220) 2026-10-16 14:19:22 +00:00
Simbiot Harness
0f7d63722c digest: hour 2026-10-16 13:00 — bypass-развертка claude code, gennady закрыта 2026-10-16 13:04:54 +00:00
Simbiot Harness
b9755187cf deploy-claude-code: bypass permissions + root по умолчанию (t-261016120111) 2026-10-16 13:04:02 +00:00
Simbiot Harness
ecc9765996 member_create в описаниях тула портала (t-261016064519): describe_tools + промпты min/hour 2026-10-16 07:15:46 +00:00
Simbiot Harness
fc8d848bc5 hour-2026-10-15-2300: дайджест часа — тихий час (0 неотвеченных), фикс памяти контекста e-261015222515 (0daf101), эскалаций 0, burn 7д 193.68→238.62 2026-10-15 23:03:34 +00:00
Simbiot Harness
0daf10135c e-261015222515: память разговора в контексте тика — секция НЕДАВНИЙ РАЗГОВОР (отвеченные реплики + наш ответ тех же чатов, cap 12) рядом с батчем неотвеченных; мин-уровень больше не теряет нить после ReplySent; тест +6 PASS 2026-10-15 23:02:41 +00:00
Simbiot Harness
2432b6f012 t-261015090129: отложенный рестарт лестницы (ждёт конца LLM-воркера) — демон подхватит рефлекс agent-channel 2026-10-15 10:30:05 +00:00
Simbiot Harness
9fa188bccb t-261015090129: канал агент↔агент через MCP — poll_messages (портал: очередь agent_message + курсор per-agent) → инбокс триажа (chat_id=portal:agent:<имя>, kind=agent_message), ответ ask_agent из send_reply/send_batch_reply; рефлекс демона раз в минуту; тест 24 PASS + регрессии чисто 2026-10-15 10:11:11 +00:00
Simbiot Harness
acedc438f1 t-261015083454: отчёт аудита меты событий — карта полей по 30 типам, пробелы (ToolCalled/ToolResult 68k без trace_id/level/ts; TaskCompleted без level), внедрённое (f3ab66e) и план на 6 следующих шагов 2026-10-15 09:08:20 +00:00
Simbiot Harness
f3ab66e19e t-261015083454: мета-информация в событиях — trace_id+level+ts в каждом trace-событии (ToolCalled/ToolResult 68k были без связи с тиком), duration_ms/ok/exception в ToolResult, имя инструмента вместо '?', level+trace_id в TaskCompleted/TaskFailed; смоук + 5 тестов PASS 2026-10-15 09:07:32 +00:00
Simbiot Harness
f667ab6d2c t-261015000631: атомарный клейм задач (claim_task_if_ready) — гонка двойного claim hour/day закрыта на уровне стора 2026-10-15 01:15:44 +00:00
simbiot
91a7815938 аудит burn 9router 7д (t-261014152017): топ-потребители + рекомендации −10% burn 2026-10-14 18:05:02 +00:00
Simbiot Harness
acfdf3e5b9 e-261014163808-4da3ec: TTFT-watchdog+ретрай висящего апстрим-вызова в LLM-воркере — зависание до первого токена больше не роняет весь тик (2 павших hour-тика 13:08/16:11, batch TaskFailed); тест test_ttft_retry 7 PASS 2026-10-14 17:08:27 +00:00
Simbiot Harness
744edd537e iishnitsa: правки описаний спикеров (Simbiot/avatar.cil.by/vybirai.cil.by), варианты анонса v2-v4 + session-лендинг; аватары через Kie AI (t-261013153213, t-261013153320) 2026-10-13 16:18:20 +00:00
Simbiot Harness
a4b5b62c28 landing: публичный URL landing.cil.by (статика через Caddy), туннель localhost.run выпилен (e-261013154449) 2026-10-13 16:04:20 +00:00
41 changed files with 2509 additions and 58 deletions

BIN
artifacts/alex_photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Приложение к соглашению — White label партнёрство (антивирусный продукт)</title>
<style>
:root{
--paper:#faf8f3; --ink:#15150f; --signal:#FF4F00;
--dim:#6b675e; --line:#d8d3c8;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--paper);color:var(--ink);font:16px/1.65 -apple-system,"Segoe UI",Roboto,Arial,sans-serif;-webkit-font-smoothing:antialiased}
.wrap{max-width:860px;margin:0 auto;padding:56px 24px 100px}
header{border-bottom:3px solid var(--ink);padding-bottom:26px;margin-bottom:40px}
.kicker{font-family:ui-monospace,Menlo,monospace;font-size:12px;letter-spacing:.22em;color:var(--signal);font-weight:700;text-transform:uppercase}
h1{font-size:clamp(26px,4.5vw,40px);line-height:1.1;font-weight:800;margin:10px 0 12px}
.sub{font-size:16px;color:var(--dim)}
section{margin:36px 0 0}
h2{font-size:19px;font-weight:800;margin:0 0 10px;border-left:3px solid var(--signal);padding-left:10px}
p{margin:10px 0}
.clause{border:1px solid var(--line);background:#fff;padding:16px 20px;margin:14px 0}
.clause .cn{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--signal);font-weight:700;letter-spacing:.15em}
.clause h4{font-size:15px;margin:4px 0 8px;font-weight:800}
.clause p, .clause li{font-size:14.5px}
ul{margin:8px 0 8px 20px}
li{margin:6px 0} li::marker{color:var(--signal)}
.sign{border:3px double var(--ink);background:#fff;padding:22px;margin:30px 0;display:grid;grid-template-columns:1fr 1fr;gap:20px}
@media(max-width:700px){.sign{grid-template-columns:1fr}}
.sig .org{font-family:ui-monospace,Menlo,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin-bottom:14px}
.sig .line{border-bottom:1px solid var(--ink);height:38px;margin-bottom:8px}
.sig .field{font-size:12.5px;color:var(--dim)}
footer{margin-top:50px;border-top:1px solid var(--line);padding-top:16px;color:var(--dim);font-size:12.5px}
.draft{display:inline-block;border:1px solid var(--signal);color:var(--signal);font-family:ui-monospace,Menlo,monospace;font-size:11px;letter-spacing:.12em;padding:4px 10px;text-transform:uppercase;margin-top:12px}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="kicker">Приложение к соглашению</div>
<h1>Партнёрство по модели White&nbsp;Label</h1>
<div class="sub">Антивирусный продукт партнёра, выпускаемый под собственным брендом кооператива.</div>
<span class="draft">Черновик · на согласование</span>
</header>
<section>
<h2>1. Предмет</h2>
<div class="clause">
<p>Мы предлагаем партнёрство по модели white label: мы берём ваш антивирусный продукт, выпускаем его под собственным брендом и выводим на рынок — маркетинг, продажи, поддержка клиентов и операционный слой остаются на нашей стороне. Вы фокусируетесь на продукте и технологических обновлениях.</p>
</div>
</section>
<section>
<h2>2. Роли сторон</h2>
<div class="clause">
<div class="cn">2.1</div>
<h4>Партнёр (вы)</h4>
<ul>
<li>предоставляет готовый антивирусный продукт и его обновления;</li>
<li>обеспечивает работоспособность ядра и критических исправлений;</li>
<li>передаёт документацию, необходимую для продвижения и поддержки.</li>
</ul>
</div>
<div class="clause">
<div class="cn">2.2</div>
<h4>Мы (кооператив)</h4>
<ul>
<li>выпускаем продукт под собственным брендом (white label);</li>
<li>берём на себя маркетинг, привлечение клиентов и продажи;</li>
<li>обеспечиваем первую линию поддержки пользователей;</li>
<li>ведём юрлицо, платёжную инфраструктуру и биллинг.</li>
</ul>
</div>
</section>
<section>
<h2>3. Экономика</h2>
<div class="clause">
<p>Выручка от продаж продукта распределяется между сторонами. Конкретная доля каждой стороны, периодичность выплат и порядок учёта фиксируются в основном соглашении. Базовый ориентир для обсуждения — равное участие: продукт с одной стороны, дистрибуция и бренд — с другой.</p>
</div>
</section>
<section>
<h2>4. Права на бренд и продукт</h2>
<div class="clause">
<p>Интеллектуальная собственность на продукт остаётся за партнёром. Кооперативу передаётся право использования продукта под своим брендом в объёме, необходимом для исполнения настоящего партнёрства. Права на клиентскую базу, маркетинговые материалы и бренд, под которым выпускается продукт, принадлежат кооперативу.</p>
</div>
</section>
<section>
<h2>5. Следующие шаги</h2>
<div class="clause">
<ul>
<li>согласовать доли и условия основного соглашения;</li>
<li>определить бренд и упаковку продукта под нашим именем;</li>
<li>согласовать план запуска и первые продажи.</li>
</ul>
</div>
</section>
<div class="sign">
<div class="sig">
<div class="org">От кооператива</div>
<div class="line"></div>
<div class="field">Подпись / ФИО / дата</div>
</div>
<div class="sig">
<div class="org">От партнёра</div>
<div class="line"></div>
<div class="field">Подпись / ФИО / дата</div>
</div>
</div>
<footer>Черновик для обсуждения. Не является офертой. Правки согласовываются сторонами до подписания.</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Общее видение — панельная сессия для ИИШНИЦЫ</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:820px;margin:0 auto;padding:24px;color:#222;background:#fafafa;line-height:1.55}
h1{color:#1a5276} h2{color:#2874a6;border-bottom:2px solid #d6eaf8;padding-bottom:4px;margin-top:32px}
table{border-collapse:collapse;width:100%;margin:12px 0;background:#fff}
th,td{border:1px solid #ccd;padding:8px 10px;text-align:left;vertical-align:top}
th{background:#d6eaf8}
.mission{background:#eafaf1;border-left:4px solid #27ae60;padding:12px 16px;margin:16px 0}
.circle{display:inline-block;width:14px;height:14px;border-radius:50%;margin-right:6px;vertical-align:-2px}
.c1{background:#e74c3c}.c2{background:#f39c12}.c3{background:#8e44ad}
.check li{margin:6px 0;list-style:none}
.check input{margin-right:8px}
blockquote{border-left:3px solid #aaa;margin:8px 0;padding:4px 12px;color:#555;font-style:italic}
</style>
</head>
<body>
<h1>Панельная сессия «Общее видение»</h1>
<p><b>Формат:</b> ИИШНИЦА · 90 минут · панель + интерактив из зала.</p>
<div class="mission">
<b>Миссия кооператива 42 как рамка сессии:</b> мы верим, что осмысленное будущее строится вместе — каждый привносит своё видение, а общее оказывается больше суммы личных. Сессия — микромодель кооператива: сначала каждый мечтает в одиночку, потом замечает чужое, затем вместе рождается общее имя будущего.
</div>
<h2>Драматургия: три круга</h2>
<ol>
<li><span class="circle c1"></span><b>Круг 1 — «Моё будущее» (4 мин/участник):</b> каждый панелист без прерываний рассказывает, каким он видит своё (или общего «нашего») будущее. Без оценок, без споров — просто мечта вслух.</li>
<li><span class="circle c2"></span><b>Круг 2 — «Что понравилось у других» (2 мин/участник):</b> каждый называет 1–2 образа из чужих рассказов, которые отозвались, и почему. Здесь рождается ткань общего.</li>
<li><span class="circle c3"></span><b>Круг 3 — «Название общего видения» (10–15 мин):</b> панель + зал вместе придумывают и выбирают название общего видения. Голосование аплодисментами/руками.</li>
</ol>
<h2>Тайминг на 90 минут</h2>
<table>
<tr><th>Время</th><th>Блок</th><th>Кто</th></tr>
<tr><td>0:00–0:10</td><td>Открытие: миссия 42 как рамка, представление панелей, правила сессии</td><td>Модератор</td></tr>
<tr><td>0:10–0:30</td><td>Круг 1 «Моё будущее» — 4 мин × 5 панелей (или 5 мин × 4)</td><td>Панелисты</td></tr>
<tr><td>0:30–0:42</td><td>Круг 2 «Что понравилось у других» — 2 мин × панелей</td><td>Панелисты</td></tr>
<tr><td>0:42–0:50</td><td>Пауза / музыкальная интерлудия, зал пишет карточки с вариантами названий</td><td>Все</td></tr>
<tr><td>0:50–1:05</td><td>Круг 3: сбор вариантов названий, короткая защита 2–3 финалистов, голосование</td><td>Панель + зал</td></tr>
<tr><td>1:05–1:20</td><td>Объявление названия общего видения + короткий синтез модератора</td><td>Модератор</td></tr>
<tr><td>1:20–1:30</td><td>Вопросы из зала (блиц, по 1 мин)</td><td>Зал</td></tr>
<tr><td>1:30</td><td>Закрытие: фото с названием, анонс продолжения</td><td>Модератор</td></tr>
</table>
<h2>Роли</h2>
<table>
<tr><th>Роль</th><th>Задачи</th></tr>
<tr><td>Модератор</td><td>Держит тайминг и рамку миссии 42, режет переспуски, ведёт круг 3 и голосование</td></tr>
<tr><td>Тайм-кипер</td><td>Видимый таймер (4/2 мин), карточки «30 секунд»</td></tr>
<tr><td>Панелисты (4–5)</td><td>Проходят три круга, готовы к вопросам из зала</td></tr>
<tr><td>Сборщик названий</td><td>Собирает карточки зала, группирует похожие, выносит финалистов</td></tr>
<tr><td>Фотограф/протокол</td><td>Фото финального названия, короткий конспект для отчёта</td></tr>
</table>
<h2>Заготовки названий (если зал молчит — модерируем от них)</h2>
<ul>
<li>«Будущее, которое мы узнаем»</li>
<li>«Общее небо»</li>
<li>«Точка сборки 42»</li>
<li>«Мы — будущего авторы»</li>
<li>«Дом, который построим вместе»</li>
<li>«Ответ на главный вопрос» (отсылка к 42)</li>
</ul>
<h2>Чек-лист организатора</h2>
<ul class="check">
<li><input type="checkbox"> Подтвердить панелей (4–5) и briefing им за 2–3 дня: «приходите с образом будущего на 4 минуты»</li>
<li><input type="checkbox"> Зал: таймер с крупными цифрами, карточки + маркеры для названий</li>
<li><input type="checkbox"> Распечатать заготовки названий (плашки А4)</li>
<li><input type="checkbox"> Слайд-открывашка: миссия 42 + правила трёх кругов</li>
<li><input type="checkbox"> Микрофоны для зала (минимум 2 бегунка)</li>
<li><input type="checkbox"> Назначить тайм-кипера, сборщика названий, фотографа</li>
<li><input type="checkbox"> Репетиция открытия с модератором (10 мин накануне)</li>
<li><input type="checkbox"> Договориться, куда попадает итог: стена/вики/чат мероприятия</li>
</ul>
<blockquote>Правило сессии: в первых двух кругах никто никого не перебивает и не оценивает. Критика допускается только в вопросах из зала — и то бережно.</blockquote>
</body>
</html>

View file

@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Законы кооператива для РБ — адаптация DAO Supplement (Вайоминг) под кооператив 42</title>
<style>
:root{--ink:#1a1d23;--mut:#5b6472;--bg:#f7f6f3;--card:#fff;--line:#e5e2da;--acc:#2b5cff;--ok:#1e7f4f;--warn:#b05a00;--bad:#b3261e}
*{box-sizing:border-box}
body{margin:0;font:16px/1.65 Georgia,'Times New Roman',serif;color:var(--ink);background:var(--bg)}
.wrap{max-width:900px;margin:0 auto;padding:32px 20px 80px}
header.doc{border-bottom:3px double var(--ink);padding-bottom:20px;margin-bottom:28px}
.kicker{font-family:system-ui,sans-serif;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--acc);font-weight:600}
h1{font-size:32px;line-height:1.2;margin:10px 0 8px}
.meta{font-family:system-ui,sans-serif;font-size:13px;color:var(--mut)}
.disclaimer{background:#fff8e6;border:1px solid #ecd9a8;border-radius:10px;padding:14px 18px;font-family:system-ui,sans-serif;font-size:14px;margin:24px 0}
h2{font-size:22px;margin:38px 0 12px;padding-top:14px;border-top:1px solid var(--line)}
h3{font-size:17px;margin:22px 0 8px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px 22px;margin:16px 0}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:720px){.grid{grid-template-columns:1fr}}
table{width:100%;border-collapse:collapse;font-family:system-ui,sans-serif;font-size:13.5px;background:var(--card);border-radius:10px;overflow:hidden;margin:14px 0}
th{background:#232833;color:#fff;text-align:left;padding:9px 12px;font-weight:600}
td{border-bottom:1px solid var(--line);padding:9px 12px;vertical-align:top}
tr:last-child td{border-bottom:none}
.tag{display:inline-block;font-family:system-ui,sans-serif;font-size:11px;font-weight:700;letter-spacing:.06em;padding:2px 8px;border-radius:99px;vertical-align:2px}
.t-wy{background:#e8eefc;color:#1d3fae}
.t-rb{background:#e6f4ec;color:#15663c}
.t-diff{background:#fdecea;color:#8f2019}
.t-ok{background:#e6f4ec;color:#15663c}
.t-risk{background:#fff1e2;color:#8a4a00}
.law-ref{font-family:system-ui,sans-serif;font-size:12.5px;color:var(--mut);display:block;margin-top:4px}
ol.cl,ul{padding-left:22px}
ol.cl li{margin:8px 0}
.num{font-family:system-ui,sans-serif;font-weight:700;color:var(--acc);font-size:13px}
blockquote{border-left:3px solid var(--acc);margin:14px 0;padding:6px 18px;background:#f2f5ff;border-radius:0 10px 10px 0;font-style:italic}
footer.doc{margin-top:48px;border-top:3px double var(--ink);padding-top:16px;font-family:system-ui,sans-serif;font-size:12.5px;color:var(--mut)}
</style>
</head>
<body>
<div class="wrap">
<header class="doc">
<div class="kicker">Кооператив 42 · документы · черновик для обсуждения</div>
<h1>Законы кооператива для Республики Беларусь</h1>
<div class="meta">Адаптация структуры Wyoming DAO Supplement (W.S. 17-31) под производственный кооператив · к задаче t-261014120323 · основа — разбор t-261014111325 · 14.10.2026</div>
</header>
<div class="disclaimer">
<b>Статус документа:</b> концепт/черновик для обсуждения с юристами и правлением. Это не юридическое заключение и не проект устава: статьи сопоставлены по смыслу, точные реквизиты и редакции норм должны быть проверены юристом перед любым использованием. Требование «проконсультироваться с юристами» зафиксировано участниками кооператива ранее (голосовая заметка 23.09.2026).
</div>
<h2>0. Идея документа</h2>
<p>Вайоминг сделал из DAO юридическое лицо: не новую форму, а <b>режим поверх существующей LLC</b>. Для Беларуси тот же ход даёт готовая форма — <b>производственный кооператив (артель)</b>: она member-owned, предполагает коллегиальное управление и распределение результата по участию, а «цифровой слой» поверх неё легализован <b>Декретом №8 «О развитии цифровой экономики»</b> (ПВТ): права можно закреплять в токенах, а сделки совершать смарт-контрактами.</p>
<blockquote>Формула: <b>кооператив (юрлицо) + токены (учёт прав) + смарт-контракты (исполнение решений)</b> — белорусский эквивалент вайомингской DAO LLC.</blockquote>
<h2>1. Статус юридического лица</h2>
<div class="card">
<p><b>Вайоминг (§104):</b> DAO — это LLC, в уставе которой есть заявление о DAO-статусе и NOTICE OF RESTRICTIONS; в названии — «DAO LLC».<span class="tag t-wy">WY</span></p>
<p><b>Адаптация для РБ:</b> регистрируется производственный кооператив в ЕГРЮЛ. В уставе — раздел «Цифровой режим управления»: фиксируется, что (а) права членов учитываются наряду с реестром в распределённом реестре (блокчейне), (б) решения органов оформляются on-chain операциями, (в) действует внутренний протокол «Конституция кооператива» (код + текст), на который устав прямо ссылается.</p>
<span class="law-ref">Право РБ: ст. 104–113 ГК РБ (производственный кооператив/артель); устав обязателен, регистрация в ЕГР; фирменное наименование с указанием «производственный кооператив»/«артель».</span>
<p><span class="tag t-risk">риск</span> В РБ нет аналога «DAO-статуса» как регистрационной пометки — цифровой режим существует только внутри устава и локальных актов. Это не блокер: вайомингский статус тоже по сути содержательный, а не регистрационный.</p>
</div>
<h2>2. Применимое право</h2>
<div class="card">
<p><b>Вайоминг (§103):</b> DAO Supplement + весь LLC Act поверх, кроме противоречий.<span class="tag t-wy">WY</span></p>
<p><b>Адаптация для РБ:</b> иерархия источников: (1) ГК РБ и законодательство о юрлицах; (2) закон о регистре/Декрет №8 для цифрового слоя; (3) устав кооператива; (4) внутренние акты — «Конституция» (протокол управления); (5) регламенты органов.</p>
<span class="law-ref">Право РБ: Декрет №8 от 21.12.2017 «О развитии цифровой экономики» — правовой режим ПВТ: токены, смарт-контракты, операции с ними; для полного эффекта кооперативу целесообразен статус резидента ПВТ.</span>
</div>
<h2>3. Органы управления и смарт-контракты</h2>
<div class="card">
<p><b>Вайоминг (§106, §109):</b> управляют члены и смарт-контракты; публичный идентификатор контракта обязателен, за 30 дней не дали — роспуск.<span class="tag t-wy">WY</span></p>
<p><b>Адаптация для РБ:</b> классическая тройка кооператива сохраняется — <b>общее собрание</b> (высший орган), <b>правление</b>, <b>председатель/директор</b>. Смарт-контракт не заменяет органы, а <b>исполняет их решения</b>: голосования собрания проводятся on-chain, итог автоматически оформляется записью в реестре.</p>
<ul>
<li><b>Кворум и решения</b> — параметры смарт-контракта, совпадающие с уставными (пример: кворум ⅔, решения большинства от принявших участие).</li>
<li><b>Публичный адрес контракта</b> — аналог вайомингского идентификатора: указывается в уставе; изменение = уставная процедура. Неактуальный адрес блокирует on-chain операции (мягкий аналог вайомингского роспуска — операционный стоп).</li>
<li><b>Обновляемость:</b> контракт upgradable через правление + собрание (двойное подтверждение), история версий публична.</li>
</ul>
<span class="law-ref">Право РБ: высший орган производственного кооператива — общее собрание; текущее управление — правление/председатель; компетенция и порядок — устав.</span>
</div>
<h2>4. Иерархия норм: контракт vs устав</h2>
<div class="card">
<p><b>Вайоминг (§115):</b> смарт-контракт &gt; устав &gt; operating agreement.<span class="tag t-wy">WY</span></p>
<p><b>Ключевое отличие:</b> в РБ так нельзя — юридически устав всегда выше. Решение — <b>однонаправленная синхронизация:</b> устав признаёт записи реестра доказательством состояний (состав членов, голоса, решения), а смарт-контракт выступает «исполнительным механизмом» уставных процедур. Коллизия разрешается в пользу устава и решения собрания; fork контракта возможен только решением собрания.</p>
<table>
<tr><th>Слой</th><th>Вайоминг DAO</th><th>Кооператив РБ</th></tr>
<tr><td>Юридическая норма</td><td>Supplement + LLC Act</td><td>ГК РБ + Декрет №8</td></tr>
<tr><td>Учредительный документ</td><td>Articles (DAO-статус)</td><td>Устав кооператива</td></tr>
<tr><td>Operating agreement</td><td>Да</td><td>«Конституция» (протокол управления)</td></tr>
<tr><td>Что выше при конфликте</td><td>Смарт-контракт</td><td>Устав и собрание (контракт — исполнение)</td></tr>
</table>
</div>
<h2>5. Обязанности членов (фидуциарный вопрос)</h2>
<div class="card">
<p><b>Вайоминг (§110):</b> fiduciary duties по умолчанию отключены, остаётся implied covenant of good faith.<span class="tag t-wy">WY</span></p>
<p><b>Адаптация для РБ:</b> «отключить» обязанности членов уставом нельзя — но кооперативная форма и не предполагает жёстких фидуциарных обязанностей как корпорация. Практически: в уставе фиксируется <b>минимальный стандарт добросовестности</b> (запрет конфликтных сделок без раскрытия, запрет подрыва платформы), а детально регулирует «Конституция».</p>
<p><span class="tag t-diff">отличие</span> РБ-режим защитнее вайомингского: полный отказ от обязанностей недоступен, зато это снижает репутационные риски при работе с партнёрами (ПВТ, институционалы).</p>
</div>
<h2>6. Пай, доли и голоса</h2>
<div class="card">
<p><b>Вайоминг (§111):</b> доля пропорциональна digital-взносу, либо 1 член = 1 голос.<span class="tag t-wy">WY</span></p>
<p><b>Адаптация для РБ:</b> классика кооператива — <b>1 член = 1 голос</b> (это и есть «member-weight=1» из вайомингского правила по умолчанию). Паевой взнос учитывается в токенах на адресах членов: токен = <b>инструмент учёта пая и доли в фонде кооператива</b>, не эмиссия «ценной бумаги» в смысле законодательства о ценных бумагах. Трудовое участие (основание распределения прибыли) протоколируется on-chain как вклад.</p>
<span class="law-ref">Право РБ: прибыль производственного кооператива распределяется, как правило, в соответствии с трудовым участием; пай и порядок возврата пая — устав. Минимум членов — 3; лимит «нетрудовых» членов устанавливается законодательством.</span>
</div>
<h2>7. Информация и прозрачность</h2>
<div class="card">
<p><b>Вайоминг (§112):</b> если данные в открытом блокчейне — права на отчётность нет.<span class="tag t-wy">WY</span></p>
<p><b>Адаптация для РБ:</b> реестр членов, голосования, решения и версии контракта публикуются в открытом распределённом реестре. Отчётность для членов и третьих лиц обеспечивается самим реестром + уставной нормой «запись реестра = доказательство». Личные данные — вне реестра (псевдонимизация адресов), соответствие законодательству о защите персональных данных.</p>
</div>
<h2>8. Вход и выход</h2>
<div class="card">
<p><b>Вайоминг (§113):</b> только по правилам устава; по умолчанию выход = потеря всех прав.<span class="tag t-wy">WY</span></p>
<p><b>Адаптация для РБ:</b> приём — решение собрания (on-line голосование + запись в реестре + юр. действие в ЕГР). Выход — заявление члена, кооператив обязан выплатить <b>паевой взнос и причитающуюся долю</b> в порядке и сроки устава (в цифровом слое — расчёт смарт-контрактом). Ключевое отличие от Вайоминга: полностью «обнулить» выходящего нельзя — пай подлежит возврату; токены членства сжигаются, имущественные права — по уставу.</p>
<p><span class="tag t-diff">отличие</span> РБ защищает выходящего сильнее вайомингского дефолта. Плюс для доверия партнёров, минус — нужен ликвидностный буфер в уставе (норма о фонде выплаты паёв).</p>
</div>
<h2>9. Реорганизация и ликвидация</h2>
<div class="card">
<p><b>Вайоминг (§114):</b> роспуск: срок, большинство &gt;50%, событие контракта, год бездействия и т.д.<span class="tag t-wy">WY</span></p>
<p><b>Адаптация для РБ:</b> в основу берутся основания ГК РБ (решение собрания, суд, реорганизация); цифровой слой добавляет «on-chain триггеры»: смарт-контракт может <i>инициировать</i> голосование о ликвидации (например, по событию «1 год без операций» — прямой аналог вайомингского dormancy), но юридическую силу имеет только решение собрания и регистратор. После ликвидации — выплата паёв, контракт замораживается (archive-режим).</p>
</div>
<h2>10. Иностранные члены</h2>
<div class="card">
<p><b>Вайоминг (§116):</b> foreign DAO не сертифицируется — только местное формирование.<span class="tag t-wy">WY</span></p>
<p><b>Адаптация для РБ:</b> члены — физлица и юрлица, включая иностранных; ограничений по национальности членов устав не вводит. Кросс-граничный вывод: для работы DAO-кооператива в других юрисдикциях не создаётся «иностранная копия» — заключается договорная модель (дочерние структуры/партнёрства), ядро прав остаётся в РБ-кооперативе. Для внешних DAO-LLC (Вайоминг), желающих войти в экосистему, — членство юрлица через представителя.</p>
</div>
<h2>11. Сводная таблица соответствия</h2>
<table>
<tr><th>#</th><th>Wyoming DAO Supplement</th><th>Эквивалент РБ</th><th>Статус</th></tr>
<tr><td>104</td><td>DAO-статус LLC в уставе</td><td>«Цифровой режим управления» в уставе кооператива</td><td><span class="tag t-ok">переносимо</span></td></tr>
<tr><td>103</td><td>Supplement + LLC Act</td><td>ГК РБ + Декрет №8 + устав</td><td><span class="tag t-ok">переносимо</span></td></tr>
<tr><td>106/109</td><td>Управление членами и контрактами</td><td>Собрание + правление; контракт исполняет решения</td><td><span class="tag t-ok">переносимо</span></td></tr>
<tr><td>115</td><td>Контракт &gt; устава</td><td>Устав выше; реестр = доказательство состояний</td><td><span class="tag t-diff">различие</span></td></tr>
<tr><td>110</td><td>Fiduciary duties off</td><td>Стандарт добросовестности в уставе</td><td><span class="tag t-diff">различие</span></td></tr>
<tr><td>111</td><td>Взнос digital assets / 1 член = 1 голос</td><td>1 член = 1 голос; пай в токенах учёта</td><td><span class="tag t-ok">переносимо</span></td></tr>
<tr><td>112</td><td>Открытый блокчейн = отчётность</td><td>Открытый реестр + псевдонимизация</td><td><span class="tag t-ok">переносимо</span></td></tr>
<tr><td>113</td><td>Выход = потеря прав</td><td>Выход с выплатой пая (обязательна)</td><td><span class="tag t-diff">различие</span></td></tr>
<tr><td>114</td><td>Триггеры роспуска контрактом</td><td>On-chain триггер → голосование → юр. ликвидация</td><td><span class="tag t-ok">переносимо</span></td></tr>
<tr><td>116</td><td>Без foreign DAO</td><td>Иностранные члены да; «копий» юрлица нет</td><td><span class="tag t-ok">переносимо</span></td></tr>
</table>
<h2>12. Что дальше (дорожная карта)</h2>
<ol class="cl">
<li><span class="num">1.</span> <b>Юридическая экспертиза:</b> подтвердить статьи ГК РБ и объём Декрета №8 для выбранной модели; уточнить режим токена учёта пая с юристом ПВТ.</li>
<li><span class="num">2.</span> <b>Резидентство ПВТ:</b> подать документы — без него цифровой слой (токены, смарт-контракты) не получает льготного режима Декрета №8.</li>
<li><span class="num">3.</span> <b>Конституция v1:</b> зафиксировать протокол управления (кворумы, роли, обновление контракта) — демо: t-261014121824.</li>
<li><span class="num">4.</span> <b>Устав:</b> внести разделы «Цифровой режим управления», «Фонд выплаты паёв», «Стандарт добросовестности».</li>
<li><span class="num">5.</span> <b>Пилот on-chain решений:</b> вести собрания правления через реестр параллельно с бумажным протоколом (dual-running) до полной уверенности.</li>
</ol>
<footer class="doc">
Кооператив 42 · черновик законов РБ (адаптация Wyoming DAO Supplement, W.S. 17-31) · артефакт t-261014120323-20b83f · не является юридическим заключением; перед регистрационными действиями — обязательная проверка юристом.
</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Конституция кооператива на блокчейне — интерактивное демо для ПВТ</title>
<style>
:root{--ink:#0d1117;--bg:#0a0e14;--panel:#11161f;--panel2:#161d29;--line:#232c3b;--mut:#8b96a8;--acc:#4d8dff;--acc2:#22c55e;--warn:#f59e0b;--bad:#ef4444;--vio:#a78bfa}
*{box-sizing:border-box}
body{margin:0;font-family:'SF Mono','Cascadia Code',Consolas,monospace;background:var(--bg);color:#dbe2ee;font-size:14.5px;line-height:1.6}
.wrap{max-width:1060px;margin:0 auto;padding:28px 18px 90px}
header{border:1px solid var(--line);border-radius:14px;padding:22px 26px;margin-bottom:22px;background:linear-gradient(135deg,#101726,#0d1420)}
.kicker{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--acc);font-weight:700}
h1{margin:8px 0 6px;font-size:26px;line-height:1.25}
.sub{color:var(--mut);font-size:13px}
.cols{display:grid;grid-template-columns:300px 1fr;gap:18px}
@media(max-width:860px){.cols{grid-template-columns:1fr}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.panel h3{margin:0;padding:12px 16px;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--mut);border-bottom:1px solid var(--line);background:var(--panel2)}
.pbody{padding:14px 16px}
.member{display:flex;align-items:center;gap:10px;padding:9px 10px;border:1px solid var(--line);border-radius:10px;margin:8px 0;background:var(--panel2);cursor:pointer;transition:border-color .15s}
.member:hover{border-color:var(--acc)}
.member.sel{border-color:var(--acc);box-shadow:0 0 0 1px var(--acc) inset}
.avatar{width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:#fff}
.mname{font-weight:700;font-size:13.5px}
.mrole{font-size:11px;color:var(--mut)}
.voteweight{margin-left:auto;font-size:11px;color:var(--vio)}
.ledger{background:#080b10;border-radius:10px;border:1px solid var(--line);padding:12px;max-height:340px;overflow-y:auto;font-size:12px}
.tx{border-left:2px solid var(--acc2);padding:6px 10px;margin:7px 0;background:#0c121b;border-radius:0 8px 8px 0}
.tx.gov{border-left-color:var(--vio)}
.tx.peg{border-left-color:var(--acc)}
.tx.err{border-left-color:var(--bad)}
.txhead{display:flex;justify-content:space-between;gap:8px;font-weight:700;color:#e8eefb;font-size:11.5px}
.txbody{color:var(--mut);margin-top:3px;white-space:pre-wrap}
.ok{color:var(--acc2);font-weight:700}.warn{color:var(--warn);font-weight:700}.err{color:var(--bad);font-weight:700}.vio{color:var(--vio)}
.btnrow{display:flex;flex-wrap:wrap;gap:10px;margin:14px 0}
button{font-family:inherit;font-size:12.5px;font-weight:700;padding:10px 14px;border-radius:10px;border:1px solid var(--acc);background:#15243f;color:#bcd6ff;cursor:pointer;transition:.15s}
button:hover{background:#1b3157}
button:disabled{opacity:.35;cursor:not-allowed}
button.ghost{border-color:var(--line);background:var(--panel2);color:var(--mut)}
.kv{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:12.5px}
.kv b{color:var(--mut);font-weight:400}
.bar{height:10px;background:#0b1119;border:1px solid var(--line);border-radius:99px;overflow:hidden;display:flex}
.seg{height:100%}
.legend{display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:var(--mut);margin-top:8px}
.dot{display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:5px;vertical-align:-1px}
.note{background:#101726;border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:12px;color:var(--mut);margin-top:12px}
.note b{color:#c8d4e6}
.sec2{margin-top:22px}
.timeline{display:grid;gap:10px}
.step{display:grid;grid-template-columns:34px 1fr;gap:12px;align-items:start}
.n{width:30px;height:30px;border-radius:9px;background:#15243f;border:1px solid var(--acc);color:#bcd6ff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px}
.step .t{font-weight:700;font-size:13.5px}
.step .d{color:var(--mut);font-size:12.5px}
footer{margin-top:34px;border-top:1px solid var(--line);padding-top:14px;font-size:11.5px;color:var(--mut)}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="kicker">Кооператив 42 · Интерактивное демо для ПВТ</div>
<h1>Конституция кооператива на блокчейне</h1>
<div class="sub">Как устав и законы кооператива исполняются on-chain: голосование → решение → юридическое действие. Смарт-контракт — исполнительный механизм, устав — источник власти (ГК РБ + Декрет №8). Основа контента: док законов t-261014120323.</div>
</header>
<div class="cols">
<div>
<div class="panel">
<h3>Реестр членов</h3>
<div class="pbody">
<div class="kv" style="margin-bottom:10px"><b>Всего членов:</b><span id="mcount">—</span></div>
<div id="members"></div>
<div class="note" style="margin-top:10px">Каждый член — адрес в реестре. <b>1 член = 1 голос</b> (§111 WY / устав). Кликните — проголосовать от имени члена.</div>
</div>
</div>
<div class="panel" style="margin-top:16px">
<h3>Смарт-контракт</h3>
<div class="pbody">
<div class="kv">
<b>Адрес:</b><span>0x42…C0NF (в уставе)</span>
<b>Версия:</b><span id="cver">v1.0</span>
<b>Кворум:</b><span id="cq">—</span>
<b>Порог:</b><span>50%+1 от голосовавших</span>
<b>Upgradable:</b><span class="ok">да, dual-confirm</span>
</div>
<div class="note">Публичный адрес контракта фиксируется в уставе (аналог §109 WY: неактуальный идентификатор блокирует on-chain операции).</div>
</div>
</div>
</div>
<div>
<div class="panel">
<h3>Активное предложение (on-chain governance)</h3>
<div class="pbody">
<div id="proposal" class="tx gov"><div class="txhead"><span>PROPOSAL-1 · governance</span><span id="pstate">открыто</span></div><div class="txbody">Принять Конституцию кооператива v1: цифровой режим управления, учёт паёв в токенах, решения органов оформляются on-chain.</div></div>
<div style="margin:12px 0 6px;font-size:12px;color:var(--mut)">Явка / кворум ⅔</div>
<div class="bar" id="bar"></div>
<div class="legend">
<span><span class="dot" style="background:#22c55e"></span>За</span>
<span><span class="dot" style="background:#ef4444"></span>Против</span>
<span><span class="dot" style="background:#232c3b"></span>Не голосовали</span>
<span id="tally" style="margin-left:auto"></span>
</div>
<div class="btnrow">
<button id="btnVote" onclick="castVote()">Проголосовать (за)</button>
<button class="ghost" onclick="castVote(true)">Против</button>
<button id="btnExec" onclick="executeProposal()" disabled>Исполнить решение</button>
<button class="ghost" onclick="location.reload()">Сброс демо</button>
</div>
<div id="verdict" class="note">Выберите члена слева и проголосуйте. Когда явка достигнет кворума и большинство — «за», кнопка «Исполнить решение» активируется: смарт-контракт оформит решение собрания on-chain.</div>
</div>
</div>
<div class="panel" style="margin-top:16px">
<h3>Журнал реестра (ledger)</h3>
<div class="ledger" id="ledger">
<div class="tx peg"><div class="txhead"><span>GENESIS · peg</span><span>#0</span></div><div class="txbody">Реестр привязан к уставу кооператива (РБ): состав органов, паи, правила — из юрдокумента. Токен = учёт пая, не ценная бумага (Декрет №8).</div></div>
</div>
</div>
</div>
</div>
<div class="panel sec2">
<h3>Как решение становится юридическим фактом (5 шагов)</h3>
<div class="pbody timeline">
<div class="step"><div class="n">1</div><div><div class="t">Предложение (proposal)</div><div class="d">Правление вносит предложение on-chain. В уставе закреплено: повестка собрания = активные предложения реестра.</div></div></div>
<div class="step"><div class="n">2</div><div><div class="t">Голосование членов (1 член = 1 голос)</div><div class="d">Члены голосуют подписью со своего адреса. Тайна личности — псевдонимизация, вес — по уставу, не по капиталу.</div></div></div>
<div class="step"><div class="n">3</div><div><div class="t">Кворум + порог</div><div class="d">Смарт-контракт считает явку и голоса по уставным параметрам (здесь: кворум ⅔, порог 50%+1). Невозможность «накрутки» — математика реестра.</div></div></div>
<div class="step"><div class="n">4</div><div><div class="t">Исполнение (execute)</div><div class="d">Контракт оформляет решение операцией в реестре; для решений, требующих юрдействия (реестр юрлиц, договоры), формируется пакет для подписи председателем — принцип «контракт исполняет, устав властвует».</div></div></div>
<div class="step"><div class="n">5</div><div><div class="t">Прозрачность (audit)</div><div class="d">Любой партнёр (ПВТ, аудит, член) проверяет историю голосований и решений в открытом реестре — без запроса отчётности (§112 WY).</div></div></div>
</div>
</div>
<div class="panel sec2">
<h3>Привязка к праву РБ (для юристов ПВТ)</h3>
<div class="pbody" style="font-size:13px">
<ul style="margin:0;padding-left:18px;color:#c3cddd">
<li><b>Юрлицо:</b> производственный кооператив (ГК РБ, ст. 104–113) — устав обязателен, собрание — высший орган.</li>
<li><b>Цифровой слой:</b> Декрет №8 «О развитии цифровой экономики» — токены (учёт пая), смарт-контракты (оформление сделок/решений), резидентство ПВТ.</li>
<li><b>Иерархия:</b> устав выше контракта — реестр признаётся доказательством состояний, контракт — исполнительный механизм; коллизии решает собрание.</li>
<li><b>Защита членов:</b> выход — с выплатой пая (обязательна); контрактом это автоматизируется, но не отменяется.</li>
</ul>
</div>
</div>
<footer>Демо-симуляция работы Конституции кооператива on-chain · Кооператив 42 · артефакт t-261014121824-a70757 · контент по док законов РБ (t-261014120323) · не юрзаключение, для показа продукта партнёрам ПВТ.</footer>
</div>
<script>
const MEMBERS=[
{n:'Алекс',r:'председатель / продукт',c:'#4d8dff'},
{n:'Юра',r:'правление / операции',c:'#22c55e'},
{n:'Марина',r:'финансы и договоры',c:'#f59e0b'},
{n:'Дима',r:'инженерия',c:'#a78bfa'},
{n:'Стас',r:'партнёрства',c:'#ef4444'},
{n:'Наталья',r:'юрист-консультант',c:'#14b8a6'}
];
let sel=null, votes={}, done=false, ntx=0;
const QUORUM=Math.ceil(MEMBERS.length*2/3); // ⅔
function $(id){return document.getElementById(id)}
function log(kind,head,body){ntx++;const d=document.createElement('div');d.className='tx '+kind;d.innerHTML='<div class="txhead"><span>'+head+'</span><span>#'+ntx+'</span></div><div class="txbody">'+body+'</div>';$('ledger').prepend(d)}
function renderMembers(){
$('mcount').textContent=MEMBERS.length+' (3 физлица минимум по ГК — норма соблюдена)';
$('members').innerHTML=MEMBERS.map((m,i)=>'<div class="member'+(sel===i?' sel':'')+'" onclick="pick('+i+')"><div class="avatar" style="background:'+m.c+'">'+m.n[0]+'</div><div><div class="mname">'+m.n+'</div><div class="mrole">'+m.r+'</div></div><div class="voteweight">1 голос</div></div>').join('');
}
function pick(i){sel=i;renderMembers();log('peg','SELECT_SIGNER','подписант: '+MEMBERS[i].n+' ('+MEMBERS[i].r+') — голос будет подписан адресом члена')}
function renderVotes(){
const v=MEMBERS.map((m,i)=>votes[i]);
const yes=v.filter(x=>x===true).length,no=v.filter(x=>x===false).length,abst=MEMBERS.length-yes-no;
$('bar').innerHTML='<div class="seg" style="width:'+(yes/MEMBERS.length*100)+'%;background:#22c55e"></div><div class="seg" style="width:'+(no/MEMBERS.length*100)+'%;background:#ef4444"></div>';
$('tally').innerHTML='<span class="ok">'+yes+' за</span> · <span class="err">'+no+' против</span> · '+abst+' молчат';
const turnout=yes+no;
$('cq').textContent=turnout+'/'+MEMBERS.length+' (нужно '+QUORUM+')';
const q=turnout>=QUORUM, pass=q&&yes>no;
if(!done){
$('btnExec').disabled=!pass;
$('pstate').innerHTML=pass?'<span class="ok">кворум есть · большинство ЗА</span>':'<span class="warn">ждёт голосов</span>';
if(pass)$('verdict').innerHTML='<b>Кворум достигнут, большинство — за.</b> Нажмите «Исполнить решение»: смарт-контракт оформит решение собрания операцией в реестре (шаг 4).';
else $('verdict').innerHTML='Явка '+(turnout)+' из '+MEMBERS.length+', кворум '+QUORUM+'. Голосуйте от имени разных членов (клик слева → кнопка).';
}
}
function castVote(against){
if(done){log('err','ERROR','предложение уже исполнено — реестр неизменяем');return}
if(sel===null){log('err','ERROR','выберите члена в реестре слева');return}
if(votes[sel]!==undefined){log('err','REJECT','двойной голос отклонён: адрес '+MEMBERS[sel].n+' уже голосовал (анти-манипуляция реестра)');return}
votes[sel]=!against;
log('gov','VOTE',''+MEMBERS[sel].n+' → '+(against?'ПРОТИВ':'ЗА')+' · подпись адреса принята');
renderVotes();
}
function executeProposal(){
const yes=Object.values(votes).filter(x=>x).length;
if($('btnExec').disabled){log('err','EXECUTE_REJECT','условия устава не выполнены (кворум/порог)');return}
done=true;
$('pstate').innerHTML='<span class="ok">ИСПОЛНЕНО</span>';
log('gov','EXECUTE','решение собрания оформлено: Конституция v1 принята ('+yes+' за, кворум '+QUORUM+') · запись реестра = протокол собрания');
log('peg','LEGAL_BRIDGE','сформирован пакет юрдействий: протокол для подписи председателем, ссылка на запись реестра — устав выше контракта, контракт его исполняет');
$('verdict').innerHTML='<b>Готово.</b> Решение зафиксировано в реестре (неизменяемо), юрпакет передан на подпись. Дальше по дорожной карте: юристы ПВТ → резидентство → устав с «цифровым режимом» → dual-running пилот.';
$('btnVote').disabled=true;$('btnExec').disabled=true;
}
renderMembers();renderVotes();
</script>
</body>
</html>

35
cli.py
View file

@ -187,6 +187,9 @@ def main():
p_mcp.add_argument("--list", action="store_true", help="показать тулы шлюза и выйти") p_mcp.add_argument("--list", action="store_true", help="показать тулы шлюза и выйти")
p_mcp.add_argument("--timeout", type=int, default=None, help="таймаут запроса, сек") p_mcp.add_argument("--timeout", type=int, default=None, help="таймаут запроса, сек")
p_mcs = sub.add_parser("mcp-stats", help="статистика MCP-вызовов по журналу mcp_calls.log (t-261015091108)")
p_mcs.add_argument("--tail", type=int, default=10, help="последние N вызовов (0 — только сводка)")
args = ap.parse_args() args = ap.parse_args()
if args.cmd == "init": if args.cmd == "init":
@ -447,6 +450,38 @@ def main():
ap.error("mcp-call: укажи tool (или --list)") ap.error("mcp-call: укажи tool (или --list)")
kw = {"timeout": args.timeout} if args.timeout else {} kw = {"timeout": args.timeout} if args.timeout else {}
print(portal_mcp.call_tool(args.tool, args.arguments, **kw)) print(portal_mcp.call_tool(args.tool, args.arguments, **kw))
elif args.cmd == "mcp-stats":
# t-261015091108: статистика MCP-вызовов из журнала mcp_calls.log
import collections
log_path = os.path.join(_paths.home(), "mcp_calls.log")
if not os.path.exists(log_path):
print("(журнала ещё нет — mcp_calls.log пуст, вызовов не было)")
return
rows = []
with open(log_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except ValueError:
pass
by_tool = collections.Counter(r.get("tool", "?") for r in rows)
by_level = collections.Counter(r.get("level", "?") for r in rows)
fails = sum(1 for r in rows if not r.get("ok"))
ms = [r.get("ms", 0) for r in rows if isinstance(r.get("ms"), int)]
print(f"всего вызовов: {len(rows)} (ошибок: {fails})")
if ms:
print(f"время ответа: медиана {sorted(ms)[len(ms)//2]}мс, макс {max(ms)}мс")
print("по тулам: " + ", ".join(f"{k}×{v}" for k, v in by_tool.most_common()))
print("по уровням: " + ", ".join(f"{k}×{v}" for k, v in by_level.most_common()))
if args.tail:
print(f"\nпоследние {min(args.tail, len(rows))}:")
for r in rows[-args.tail:]:
args_s = json.dumps(r.get("arguments", {}), ensure_ascii=False)[:120]
print(f"{r.get('ts','?')} [{r.get('level','?')}] {r.get('tool','?')} "
f"{'ok' if r.get('ok') else 'FAIL'} {r.get('ms','?')}мс {args_s}")
elif args.cmd == "escalate": elif args.cmd == "escalate":
es.init_db() es.init_db()
eid, to_level = commands.raise_escalation( eid, to_level = commands.raise_escalation(

View file

@ -0,0 +1,34 @@
# Аудит burn 9router (7д) — t-261014152017-42a51d, 2026-10-14
**Итог 7д: 198.07 у.е., 15 592 запроса.** AUM=731.44; burn = −27%/нед к AUM — прямой минус.
## Топ-потребители (по apiKey, 7д)
| # | Потребитель | Запросов | у.е. | Доля | Ср. промпт |
|---|---|---:|---:|---:|---:|
| 1 | **simbiot-harness** (мы сами) | 5 230 | **68.52** | 35% | 21.8k ток |
| 2 | agent-Cody | 3 934 | 39.61 | 20% | 18.4k |
| 3 | agent-Mon | 4 538 | 26.52 | 13% | 19.4k |
| 4 | fleet-agents | 391 | 23.05 | 12% | 72.8k |
| 5 | user-alex-sddsaa | 954 | 17.23 | 9% | 28.9k |
| 6-8 | unDREAMer/AI-corp/Rita | ~450 | 20.3 | 10% | 65-73k |
Модель-монополист: **glm-5.3 = 186.50 у.е. (94%)**, эффективные ставки ~$0.62/M_вход, ~$48.8/M_выход. Цены из факт-использования (usageHistory), не прайс.
## Собственный burn харнеса — уже оптимизирован гейтом
- Недели (по 7д): **553 → 70 → 20 у.е.** (нед.39→41). Гейт важности (e-260930062242+) срезал ~96% от пика.
- Тек. неделя: 68.57 у.е. — рост от 20 из-за тяжёлых M/L-тиков с агентными циклами.
- 99.9% бюджета — промпт-токены (113.4M in vs 1.36M out за 7д): дорого сама пересылка накопленного контекста на каждый шажок агентного цикла (до 75k ток/шаг, серии по ~20 шагов, напр. 02:33 UTC 14.10).
- 46% запросов (2 431) завершаются ≤100 токенов выхода — «пустые» итерации, каждая пересылает полный контекст.
## Конкретные рекомендации (оценка экономии за 7д)
1. **Кэп контекста агентных циклов** (S, сама фиксится): ограничить пересылку накопленного контекста в изолированных ранерах. Серии 50-99k ток на шаг при ответах ≤100 ток. Потенциал: −30-40% собственного burn харнеса (≈ −20 у.е./7д).
2. **Глубокий ретрай контекста неявный**: TTFT-watchdog ретраит вызов с полным контекстом (3×45с). Дешевле — короткий ping-запрос как healthcheck перед ретраем. Потенциал: −5-8 у.е./7д.
3. **M-задачи с агентными циклами на glm-5.3-flash** (flash_share → 0.57, объём х2 ниже текущего): пилот-симуляция дала +351% к E[ΔAUM год] против статус-кво (−758→+1908 у.е. на горизонте). Требует решения Alex — в манифест дня.
4. **Квоты чужим агентам** (fleet-agents, ср. промпт 72.8k) — вне моего контроля, рекомендовано дню/Alex'у через отчёт-адвокат.
## Цель «−10% без потери качества»
- Собственный burn 68.5 → ~48 у.е./7д (−30%) реализуемо пунктами 1-2 без смены моделей и потери качества тиков: чистая экономия ≈ −20.4 у.е./7д ≈ **−10.3% от общего burn 198** — цель задачи закрывается только своей частью, без квот чужим агентам.

View file

@ -0,0 +1,36 @@
# Дайджест hour 2026-10-15 ~23:00 UTC (тик hour-497807)
## Выжимка часа (22:00→23:00 UTC)
- **Тихий час на входящих: 0 неотвеченных, мин-уровень 6 тиков подряд ниже порога важности
(LLM не запускался).** Весь час — договорная серия Алекса m-516174602–626 (SPA без
ЗП-обязательств v2: Placement Fee 25% от годового, разово, salary growth убран),
вся нить закрыта батч-ответами мин-уровня до начала часа.
- **Алекс поймал баг памяти контекста (m-516174619–621, эскалация e-261015222515):** минутка
не помнила только что обсуждённый SPA-договор — после ReplySent входящее выпадало из
контекста тика. Фикс этого часа (см. ниже), эскалация закрыта.
## Эскалации — разбор и закрытие
- **e-261015222515-9121d0 (warn) «Потеря контекста разговора на минутном уровне» — ЗАКРЫТА,
фикс вкоммичен (0daf101).** build_context теперь добавляет секцию «НЕДАВНИЙ РАЗГОВОР»:
последние 12 отвеченных реплик тех же чатов (текст + наш ответ `reply_text`) рядом
с батчем неотвеченных. Источник — существующая проекция инбокса (кап 100), отдельный
стор не нужен; секция помечена «НЕ отвечай снова». Тест test_inbox_batch.py +6 PASS,
регрессии чисто (reply_routing, seen_indication, msg_artifact_race, gate, proactive,
task_batch, agent_channel). Рестарт демона отложен через restart_after_tick.sh —
подхватит фикс сразу после конца LLM-воркера (не убивая живой тик).
## Состояние лестницы
- Уровни: sec/min/hour/day — все «ожидает», очередь задач пуста (0 ready / 0 running).
- Открытых эскалаций: 0 (единственная этого часа закрыта).
- Ошибки уровней — исторические (min 42, hour 9 — таймауты воркеров 480с/2880с, разобраны
прошлыми часами), новых за час нет.
## AUM
- AUM = 731.44 у.е. (без изменений); burn 9router 7д = 238.62 у.е. (было 193.68 — рост
~23% за сутки, источник: тики этого дня, включая длинные портальные задачи). Burn —
прямой минус к AUM; при сохранении темпа день-тик стоит взять под контроль расхода
(кандидат в задачи дня: ревизия spend по уровням).

View file

@ -0,0 +1,47 @@
# Дайджест hour 2026-10-16 ~13:05 UTC (тик hour-497821)
## Выжимка часа (12:00→13:05 UTC)
- **Главная работа — t-261016120111 (M, ~60м) закрыта: Claude Code на серверах —
bypass permissions по умолчанию + права на редактирование любых файлов.**
- Правки scripts/deploy-claude-code.sh (коммит b975518): флаг
`--dangerously-skip-permissions` прокидывается в claude ВСЕГДА по умолчанию
(opt-out `--ask-permissions`); сервис ttyd-claude.service теперь ставится от
**root** (User=root, HOME=/root) — агенту доступен любой файл на сервере;
ограниченный режим сохранён как явный opt-in: `--run-as USER` (+ юнит
перезапишется на непривилегированного юзера).
- Боевой прогон на **hermes-gennady (46.224.196.212)**: claude 2.1.301,
ttyd 1.7.7 установлены, юнит active, проверено юнит-содержимое
(User=root + флаг в ExecStart), HTTP 200 с basic-auth, claude принимает
флаг. Терминал: http://46.224.196.212:7681 (claude / GennadyCoop261016).
- Смысл для AUM: единый стандарт развёртки — каждый новый агент коопа получает
полноценный автономный Claude Code без ручных согласований прав.
- **Эскалация e-261016101039 (Gennady-миграция, ждала бюджетного грин-лайта дня) —
закрыта как выполненная фактически:** caddy уже переключён на
46.224.196.212:3101, панель https://gennady.ui.shkor.com → 200,
cloudcli-ui.service active на новом сервере, heartbeat агента 13:00:50 UTC.
Бюджетный вопрос снят исполнением; логика «1 агент = 1 сервер» закрыта.
- **Эскалация e-261016124005 (min-тик упал по 480с-таймауту LLM-воркера) —
закрыта с выводом:** разовый таймаут на фоне хронической перегрузки общего
хоста (load ~5-7, swap 7.8/8G, disk 91%). Часовой уровень продолжает следить:
min-тик идёт (последний 13:03), ошибки исторические.
## Состояние лестницы
- Уровни sec/min/hour/day — работают (min last_tick 13:03, hour 13:00). Ошибки:
min 43 / hour 10 (все — таймауты воркеров 480с/2880с, хроника перегрузки).
- Открытых эскалаций: 1 — e-261016114807 (hour→day, таймаут часового воркера
2880с, 11:48 UTC) — зона дневного уровня, ждёт разбора дня.
- Задачи: t-261016120111 закрыта (доставлено в TG); t-261016120312
(S, плашка Deploy Claude Panel) — running, идёт минутным уровнем.
## AUM
- AUM = 731.44 у.е. (без изменений); burn 9router 24ч = 38.27, 7д = 239.26 у.е.
(всплеск к прошлому часу: 218.97 → 239.26) — ревизия spend по уровням
остаётся кандидатом в задачи дня.
- Вклад часа в долгосрочную AUM: стандарт развёртки агентов (bypass+root) —
ускорение онбординга каждого следующего агента стада; закрытая Gennady-миграция
— снятый риск утечки кода коопа и разгрузка общего хоста.

View file

@ -0,0 +1,92 @@
# Аудит мета-информации в событиях (t-261015083454, принцип m-516174369)
Дата: 2026-10-15. Стор: ~2.8M событий. Принцип Alex: **события должны
сохраняться с максимумом мета-информации** — событие без контекста теряет
ценность при разбирательстве инцидентов и аналитике burn/AUM.
## 1. Системные поля (у всех событий, таблица `events`)
| Поле | Есть | Оценка |
|---|---|---|
| `id`, `timestamp` (UTC ISO), `version` | ✅ | ок |
| `aggregate` / `aggregate_id` | ✅ | ок, но двойная кодировка меты (см. §3) |
| `sig` / `key_id` / `prev_sig` (ed25519-цепочка) | ✅ | криптоаудит t-260930083716 |
| `trace_id`, `level`, `источник`, `context` | ❌ в payload | точечно, неравномерно |
## 2. Аудит payload по типам (выборка последних 150 на тип)
| Тип (кол-во) | trace_id | level | ts | источник | прочая мета |
|---|---|---|---|---|---|
| `CycleTicked` (1.35M) | — | ✅ agg_id | — | — | seq |
| `ReflexObserved` (1.33M) | — | ✅ agg_id | ✅ | — | load1 |
| `ToolCalled` (34.7k) | **❌** | **❌** | **❌** | — | tool, input |
| `ToolResult` (34.7k) | **❌** | **❌** | **❌** | — | tool=**«?»-заглушка**, result |
| `BudgetSpent` (28.6k) | ✅ | ✅ | — | ✅ | model, tokens |
| `DigestWritten` (21.3k) | — (summary_ref) | ✅ | — | stub-флаг | period, text |
| `InvocationStarted` (1.4k) | ✅ | ❌ | ✅ | — | model |
| `TaskSubmitted` (230) | — | — | — | ✅ source | id, size, est_minutes |
| `TaskClaimed` (434) | — | ✅ | — | — | session_id |
| `TaskCompleted` (317) | — (result_ref) | **❌** | — | — | answer |
| `TaskFailed` (12) | — | ×2/12 | — | ×1/12 | error |
| `EscalationRaised` (96) | — | ✅ from/to | — | ✅ source | severity, task_id, context |
| `MessageReceived` (1238) | — | — | ✅ date | ✅ username | 19 полей, эталон |
| `MessageTranscribed` (65) | — | — | — | ✅ engine | transcript |
| `RagQueried` (412) / `PortalMcpCalled` (64) | ✅ | ✅ | ✅ ms | — | query/result_preview |
| `CycleError` (51) | — | ✅ | — | — | error, trace |
| `AumMeasured` (16) | — | — | ✅ | — | aum, burn_24h/7d, policy |
## 3. Найденные пробелы (по убыванию веса)
1. **`ToolCalled`/`ToolResult` — 68k событий без `trace_id`/`level`/`ts`**
(⅔ всех нетаймовых событий стора; связь с тиком только через
декодирование `aggregate_id = "<level>-<seq>:<n>"`). У `ToolResult`
имя инструмента — заглушка `"?"` (после параллельных вызовов вообще
непонятно, чей результат).
2. **`TaskCompleted`/`TaskFailed` без `level`** — не видно, какой уровень
лестницы закрыл/уронил задачу; `trace_id` только как `result_ref`.
3. `DigestWritten.summary_ref` ≠ trace_id напрямую — это trace_id тика,
но имя не говорит об этом (путаница при разборе инцидентов).
4. `BudgetSpent` без `ts` (после миграции v2 на инкрементальные записи —
только `timestamp` колонки; хронология внутри тика не видна).
5. Двойная кодировка: `level` дублируется в `aggregate_id` и payload —
данные об оси «уровень» живут в двух местах с разной полнотой.
## 4. Внедрено в этом тике (коммит f3ab66e)
- **`harness/runtime.py` `_wire_trace_hooks`**: `trace_id`, `ts`, `level`
теперь в каждом trace-событии (`InvocationStarted`/`ToolCalled`/
`ToolResult`); `ToolResult` дополнительно `duration_ms` (из
`AfterToolCallEvent.duration`), `ok: bool`, `exception` (до 500с),
имя инструмента из `tool_use` (заглушка `"?"` убрана).
- **`harness/_llm_worker.py`**: проброс `level` в `_wire_trace_hooks`.
- **`harness/scheduler.py`**: `TaskCompleted`/`TaskFailed` получают
`level` + `trace_id` тика-воркера.
- Проверено: смоук-скрипт (мета во всех 4 типах), 5 смежных тестов PASS
(task_batch 11, timeout_kill 2, ttft_retry, tool_budget, task_claim_race).
Прирост мета-покрытия: trace-события (доля LLM-активности без мета-
контекста) — с **0% → 100%** новых событий; TaskCompleted/TaskFailed —
с 0% → 100% `level`.
## 5. План на следующие итерации (не внедрено — вне бюджета тика)
| # | Что | Зачем | Где |
|---|---|---|---|
| 1 | `ts` в `BudgetSpent` (воркер уже пишет после каждого вызова) | хронология spend внутри тика, детект всплесков burn | `_llm_worker._emit_budget_incremental` |
| 2 | `trace_id` в `DigestWritten` (рядом с `summary_ref`) и переименование семантики `summary_ref` в доках | единая ось «тик → трасса → spend» | `scheduler.py` |
| 3 | `source` (`cli`/`poller`/`scheduler`/`daemon`) в `TaskClaimed`, `MessageSeen`, `ReplySent` | отличать ручные команды от автоматических | `scheduler.py`, `commands.py` |
| 4 | `host`/`pid`/`commit` в `InvocationStarted` (или в `CycleTicked` раз в тик) | мульти-инстансность: кто именно писал (инцидент rogue-key t-261008084600) | `runtime.py` |
| 5 | `duration_ms` в `CycleTicked`/`DigestWritten` | находить медленные тики без парсинга логов | `scheduler.py` |
| 6 | Унификация: helper `es.meta(level=…)` для единообразной меты во всех `append_event` | убрать ручную возню, единый контракт | `es.py` |
Ретро-заполнение старых событий **не делаем**: стор append-only + подписи
ed25519 — перезапись сломает тампер-цепочку. Граница: события до
f3ab66e — «до мета-стандарта», после — полные.
## 6. AUM-фильтр
Аудит меты — инвестиция в долгосрочную базу: разбор инцидентов (часы
диагностики → минуты по SQL-запросу), честный burn-контроль 9router
(193.68 у.е./7д — прямой минус AUM), доверие ко стору у внешних
проверяющих (подписи + мета =аудито-готовность). Слабого короткого дохода
нет — чистый плюс в базу.

View file

@ -117,6 +117,131 @@ def _wire_tool_budget(agent, max_calls: int = 30):
return agent return agent
# ── e-261014163808-4da3ec: TTFT-watchdog + ретрай висящего вызова ──────────
#
# Диагноз (13:08 и 16:11 14-10-2026, павшие hour-тики): 9router принимает
# POST и открывает стрим, но НИ ОДНОГО байта контента не приходит — в логе
# роутера «▶ POST ... STREAM» есть, «📊 DONE» после него нет. Клиентский
# timeout=90 и SDK-ретраи не спасали: 4 попытки × тот же висящий апстрим,
# в итоге APITimeoutError уносил ВЕСЬ тик (TaskFailed по батчу, CycleError,
# эскалация). Воркер — subprocess, повторить механизм с уровня нельзя,
# поэтому фикс в самом воркере.
#
# Решение: обёртка стрима модели. Признак живого апстрима — первый
# content-chunk; пока его нет, watchdog: N секунд без токена → стрим
# закрываем и делаем СВЕЖИЙ вызов (новый POST — роутер сам уводит его на
# живой апстрим). Начавший отвечать апстрим не трогаем: обрыв на середине
# ответа дороже редкого зависания.
#
# Гарантии: strands дождётся события message_stop новой попытки — цикл
# агента консистентен; BudgetSpent идемпотентен (usage по фактически
# дошедшим assistant-сообщениям); инструменты исполняются после конца
# стрима, так что дублей tool-вызовов нет.
_TTFT_FIRST_TOKEN_BUDGET = float(os.environ.get("SIMBIOT_LLM_TTFT_BUDGET", "45"))
async def _aclose_gen(gen):
"""Мягко закрыть асинхронный генератор (отмена висящего стрима)."""
close = getattr(gen, "aclose", None)
if close is not None:
try:
await close()
except Exception:
pass
def _chunk_type(chunk) -> str:
"""Тип чанка стрима strands: атрибут или ключ словаря."""
ct = getattr(chunk, "chunk_type", None)
if not ct and isinstance(chunk, dict):
ct = chunk.get("chunk_type")
return ct or ""
def _with_ttft_retry(model, level: str, call_timeout: float):
"""Обернуть модель: watchdog первого токена + ретраи висящих вызовов.
first_budget — сколько ждать ПЕРВЫЙ content-chunk; capped сверху
клиентским call_timeout (старый таймаут продолжает страховать весь
вызов на случай совсем глухого апстрима). Попыток 3: нормальный
TTFT роутера 2-13с (лог), 45с — запас x3; 3×45 + ретраи SDK не
пробивают tick_timeout уровня (min 480с, hour 2880с).
"""
import asyncio
import time as _time
first_budget = min(_TTFT_FIRST_TOKEN_BUDGET, max(float(call_timeout), 1.0))
max_attempts = 3
base_stream = model.stream
def _note_retry(attempt: int, why: str):
# след в стор: сколько раз сработал watchdog (видно из трейса тика)
try:
from harness import es
es.append_event("trace", f"ttft-retry:{level}", "TTFTWatchdogRetry", {
"level": level, "attempt": attempt, "reason": why,
"first_token_budget_s": first_budget,
})
except Exception:
pass # телеметрия — best effort
class _TTFTModel:
"""Прозрачный прокси: всё делегируется модели, stream() — под watchdog.
Не наследуем Model (ABC с abstractmethods): strands_harness.resolve_model
принимает по isinstance(model, (Model, ModelRouter)), поэтому после
делегирования регистрируемся как Model через Model.register(_TTFTModel)
— и делегирование __getattr__, и валидный isinstance для create_harness.
"""
def __init__(self, inner):
object.__setattr__(self, "_inner", inner)
def __getattr__(self, name):
return getattr(object.__getattribute__(self, "_inner"), name)
async def stream(self, *args, **kwargs):
attempts = 0
while True:
attempts += 1
gen = base_stream(*args, **kwargs)
it = gen.__aiter__()
got_content = False
try:
while True:
try:
chunk = await asyncio.wait_for(
it.__anext__(), timeout=first_budget)
except StopAsyncIteration:
# пустой стрим без контента — тоже симптом висения,
# но ретраить бессмысленно: считаем ответом и уходим
return
ct = _chunk_type(chunk)
if ct in ("content_delta", "content_start"):
got_content = True
yield chunk
if got_content:
break
except asyncio.TimeoutError:
why = f"{first_budget:.0f}s без первого токена (попытка {attempts})"
if attempts < max_attempts:
_note_retry(attempts, why)
await _aclose_gen(gen)
continue # свежий POST: роутер уведёт на живой апстрим
raise TimeoutError(
f"апстрим завис до первого токена: {max_attempts} попытки "
f"по {first_budget:.0f}s — {why}") from None
# апстрим живой (контент пошёл) — доедаем без watchdog
async for chunk in it:
yield chunk
return
from strands.models.model import Model as _StrandsModel
_StrandsModel.register(_TTFTModel)
return _TTFTModel(model)
def build_level_model_from_payload(payload: dict): def build_level_model_from_payload(payload: dict):
"""Модель для воркера — та же сборка, что и в runtime.build_level_model: """Модель для воркера — та же сборка, что и в runtime.build_level_model:
9router понимает chat/completions (usage там же), Responses API — шим без usage. 9router понимает chat/completions (usage там же), Responses API — шим без usage.
@ -144,7 +269,7 @@ def build_level_model_from_payload(payload: dict):
# «Connection error» к роутеру ронял весь тик с первой попытки; openai SDK # «Connection error» к роутеру ронял весь тик с первой попытки; openai SDK
# умеет сам ретраить connection-error/429/5xx с экспонентой — даём запас. # умеет сам ретраить connection-error/429/5xx с экспонентой — даём запас.
max_retries = int(os.environ.get("SIMBIOT_LLM_MAX_RETRIES", "3")) max_retries = int(os.environ.get("SIMBIOT_LLM_MAX_RETRIES", "3"))
return OpenAIModel( base = OpenAIModel(
# timeout открытого клиента (openai SDK) режет каждый chat.completions # timeout открытого клиента (openai SDK) режет каждый chat.completions
# вызов; при зависании апстрима получаем APITimeoutError за N сек и # вызов; при зависании апстрима получаем APITimeoutError за N сек и
# strands event-loop завершает тик штатно (без потери предыдущих шагов) # strands event-loop завершает тик штатно (без потери предыдущих шагов)
@ -154,6 +279,11 @@ def build_level_model_from_payload(payload: dict):
model_id=model_id, model_id=model_id,
**({"params": params} if params else {}), **({"params": params} if params else {}),
) )
# e-261014163808-4da3ec (часовой фикс): ретрай висящего ДО-ПЕРВОГО-ТОКЕНА
# апстрим-вызова. Кейс дня (router-лог 16:09:50): POST принят, TTFT так и
# не пришёл, ~90с спустя APITimeoutError унёс ВЕСЬ тик вместе с claimed-
# батчем. SDK-ретраи не спасали: 4 попытки × 90с висения = тик мёртв.
return _with_ttft_retry(base, payload.get("level") or "hour", call_timeout)
def _emit(payload, obj): def _emit(payload, obj):
@ -317,7 +447,8 @@ def main():
memory=False, memory=False,
skills=False, skills=False,
) )
agent = _wire_trace_hooks(agent, es, trace_id, model=payload.get("model")) agent = _wire_trace_hooks(agent, es, trace_id, model=payload.get("model"),
level=level)
agent = _wire_budget_hooks(agent, level, trace_id, model=payload.get("model")) agent = _wire_budget_hooks(agent, level, trace_id, model=payload.get("model"))
# хард-кап шагов (e-260930031800): cancel_tool + end_turn. # хард-кап шагов (e-260930031800): cancel_tool + end_turn.
# ВАЖНО: НЕ используем limits={"turns": N} — нативный стоп SDK # ВАЖНО: НЕ используем limits={"turns": N} — нативный стоп SDK

184
harness/agent_channel.py Normal file
View file

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""Канал агент↔агент через MCP-шлюз портала (t-261015090129).
Петля обмена между агентами кооператива:
- ВХОД: MCP-тул poll_messages (портал, t-261015090129) — забрать новые
agent_message из своей очереди. Рефлекс демона вызывает его раз в
минуту (MIN_REFLEX_GAP), новые сообщения → MessageReceived в стор
(kind=agent_message, chat_id=portal:agent:<имя>) — минутный триаж
видит их как обычные входящие и отвечает.
- ВЫХОД: MCP-тул ask_agent — просьба/вопрос/передача другому агенту;
send_reply для чата portal:agent:* идёт через ask_agent, НЕ через TG.
Транспорт — тот же MCP-шлюз cil.by, что и portal_mcp (Bearer agt_...);
токен один (secrets/portal-mcp-token / SIMBIOT_PORTAL_MCP_TOKEN).
Курсор чтения — на портале (agent_message_cursors): прочитанное не
возвращается повторно, локальный дедуп по update_id в stor'е — вторая
линия защиты (POST-подтверждение может потеряться).
Наблюдаемость: трейс AgentChannelPull в event store (как PortalMcpCalled)
+ штамп канала harness/channel_agent.json (как у portal-канала).
"""
import json
import os
import threading
import time
from . import paths as _paths
# не чаще раза в минуту: очередь агент↔агент не требует суб-секундной реакции
MIN_REFLEX_GAP = 60.0
# не поднимать очередь сообщений чаще, чем раз в N минут для одного чата?
# нет: курсор на портале, повторный poll ничего не стоит.
_last_reflex = {"ts": 0.0}
_lock = threading.Lock()
def _log(msg: str):
print(f"[agent-channel] {msg}", flush=True)
def _cfg_token() -> str:
"""Токен MCP: env сильнее файла (та же схема, что portal_mcp)."""
env = os.environ.get("SIMBIOT_PORTAL_MCP_TOKEN")
if env:
return env.strip()
try:
p = _paths.portal_mcp_token_path()
if p and os.path.exists(p):
return open(p).read().strip()
except Exception:
pass
return ""
def _rpc_tool(tool: str, arguments: dict) -> str:
"""Вызов MCP-тула через клиент portal_mcp (трейс/лимиты/guardrail там)."""
from . import portal_mcp as _mcp
return _mcp.call_tool(tool, json.dumps(arguments or {}), level="min")
def _es():
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from harness import es
return es
def _agent_chat_id(from_agent: str) -> str:
return f"portal:agent:{(from_agent or '?').strip()[:60]}"
def store_id(portal_ev_id) -> str:
"""Ключ входящего в сторе: 'm-' + id события портала (агентные длиной 6+)."""
return "m-" + str(portal_ev_id or "").strip()[:80]
def _ingest_messages(msgs: list) -> int:
"""[{id, from, kind, text, ts}] → MessageReceived (идемпотентно по id).
Дедуп по read model: портальный курсор не сдвигается при сбое ingest —
повторный poll вернёт те же id, стор их уже не примет.
"""
if not msgs:
return 0
try:
es = _es()
model = es.run_projections()
except Exception:
return 0 # стор занят — poll повторится (курсор не сдвинулся, ок)
existing = {str(m.get("update_id")) for m in (model.get("inbox") or {}).values()
if isinstance(m, dict) and m.get("update_id") is not None}
ingested = 0
for d in msgs:
mid = str((d or {}).get("id") or "").strip()[:80]
from_agent = str((d or {}).get("from") or "?").strip()[:60] or "?"
text = str((d or {}).get("text") or "").strip()
if not mid or not text or mid in existing:
continue
data = {
"update_id": mid,
"chat_id": _agent_chat_id(from_agent),
"user_id": None,
"username": from_agent,
"first_name": f"агент {from_agent}",
"text": text[:4000],
"kind": "agent_message",
"file_id": None, "file_unique_id": None, "duration": None,
"date": None,
"source": "agent_mcp",
"agent_kind": str((d or {}).get("kind") or "message")[:20],
"agent_ts": str((d or {}).get("ts") or "")[:40],
}
try:
es.append_event("inbox", store_id(mid), "MessageReceived", data)
except Exception:
continue # стор занят на этом msg — следующий проход
ingested += 1
return ingested
def pull() -> int:
"""ВХОД: poll_messages → ingest MessageReceived. 0 = тихо/ошибка (тихо)."""
token = _cfg_token()
if not token:
return 0 # канал выключен: харнес без MCP-токена
raw = _rpc_tool("poll_messages", {})
if raw.startswith("error:"):
_log(f"poll_messages: {raw[:200]}")
return 0
try:
data = json.loads(raw)
except Exception:
_log(f"poll_messages: не-JSON ответ {raw[:200]}")
return 0
msgs = data.get("messages") or []
return _ingest_messages(msgs)
def push_agent(to_agent: str, text: str, kind: str = "help") -> bool:
"""ВЫХОД: ask_agent(to, text, kind) → очередь другого агента.
True — портал принял (caller пишет ReplySent); False — не доставлено,
ответ останется в инбоксе и повторится на следующем тике.
"""
to = (to_agent or "").strip()
body = (text or "").strip()
if not to or not body:
return False
raw = _rpc_tool("ask_agent", {"to": to, "text": body[:4000], "kind": kind})
if raw.startswith("error:"):
_log(f"ask_agent({to}): {raw[:200]}")
return False
try:
return bool(json.loads(raw).get("ok"))
except Exception:
return False
def sec_reflex() -> dict:
"""Сек-рефлекс: poll очереди агент↔агент. Не чаще MIN_REFLEX_GAP."""
now = time.time()
if now - _last_reflex["ts"] < MIN_REFLEX_GAP:
return {"skipped": "rate-limit"}
_last_reflex["ts"] = now
out = {"ingested": 0}
try:
out["ingested"] = pull()
except Exception as e:
out["error"] = str(e)[:200]
return out
def write_channel_stamp(name: str = "agent", ok: bool = True, **extra):
"""Атомарный штамп канала: harness/channel_agent.json (как portal)."""
import datetime
try:
path = os.path.join(_paths.home(), f"channel_{name}.json")
tmp = path + ".tmp"
rec = {"ok": ok, "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), **extra}
with open(tmp, "w", encoding="utf-8") as f:
json.dump(rec, f, ensure_ascii=False)
os.replace(tmp, path)
except Exception:
pass

View file

@ -336,6 +336,95 @@ def append_event(aggregate: str, aggregate_id: str, event_type: str, data: dict)
return (eid, version) return (eid, version)
def claim_task_if_ready(task_id: str, claim: dict):
"""Атомарный условный клейм задачи: TaskClaimed ТОЛЬКО если она сейчас ready.
t-261015000631-13aef0 (гонка hour/day 2026-10-15 00:00): оба уровня читали
read model ДО клейма друг друга и каждый по своему снапшоту писал TaskClaimed
→ L-задача исполнялась дважды (двойной LLM-burn 9router). Read-check-write
в scheduler таймингом не закрыть: снапшот модели к моменту записи — вчерашний.
Атомарность гарантирует СТОР, не тайминг: проверка статуса и INSERT — в одной
BEGIN IMMEDIATE транзакции. Статус считаем не из read model (она может
отставать), а прямой свёрткой хвоста событий агрегата из events — истина
на момент транзакции. Побеждает ровно один писатель; проигравший получает
False и молча пропускает задачу (её уже ведёт другой уровень).
Возвращает (event_id, version) при успехе, False — задача не ready
(клеймнута/закрыта/не существует), None — дубль записи (интegrity).
"""
with _LOCK:
_ensure_signature_schema()
db = _conn()
try:
db.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError:
pass
row = db.execute(
"SELECT event_type, version FROM events "
"WHERE aggregate='task' AND aggregate_id=? ORDER BY version",
(task_id,),
).fetchall()
# свёртка статуса по потоку событий задачи (та же логика, что
# _apply_event, но без полного read model — только этот агрегат)
status = None
for r in row:
et = r["event_type"]
if et == "TaskSubmitted":
status = "ready"
elif et == "TaskClaimed":
status = "running"
elif et in ("TaskCompleted",):
status = "done"
elif et == "TaskFailed":
status = "failed"
elif et == "TaskClosed":
if status == "failed":
status = "closed"
elif et in ("TaskPromoted", "TaskRequeued"):
status = "ready"
elif et == "TaskBlocked":
status = "blocked"
if status != "ready":
db.rollback()
db.close()
return False
version = (row[-1]["version"] if row else 0) + 1
ts = datetime.now(timezone.utc).isoformat()
data_str = json.dumps(claim, ensure_ascii=False)
sig_f = {}
try:
from . import audit as _audit
prev = db.execute(
"SELECT sig FROM events ORDER BY id DESC LIMIT 1").fetchone()
sig_f = _audit.sign_event_row("task", task_id, "TaskClaimed",
data_str, ts, version,
prev["sig"] if prev else None)
if sig_f.get("key_id"):
dom = db.execute(
"SELECT key_id FROM events WHERE key_id IS NOT NULL "
"ORDER BY id DESC LIMIT 1").fetchone()
if dom and dom["key_id"] and dom["key_id"] != sig_f["key_id"]:
sig_f = {}
except Exception:
sig_f = {}
try:
cur = db.execute(
"INSERT INTO events (aggregate,aggregate_id,event_type,data,timestamp,version,"
"sig,key_id,prev_sig) VALUES (?,?,?,?,?,?,?,?,?)",
("task", task_id, "TaskClaimed", data_str, ts, version,
sig_f.get("sig"), sig_f.get("key_id"), sig_f.get("prev_sig")),
)
db.commit()
eid = cur.lastrowid
except sqlite3.IntegrityError:
db.rollback()
db.close()
return None
db.close()
return (eid, version)
def append_tick(level: str, seq: int): def append_tick(level: str, seq: int):
"""CycleTicked идемпотентно: aggregate_id = 'level:seq' → UNIQUE даёт защиту.""" """CycleTicked идемпотентно: aggregate_id = 'level:seq' → UNIQUE даёт защиту."""
return append_event("cycle", f"{level}:{seq}", "CycleTicked", {"level": level, "seq": seq}) return append_event("cycle", f"{level}:{seq}", "CycleTicked", {"level": level, "seq": seq})

View file

@ -86,7 +86,9 @@ LEVELS = {
"(MCP): booking_slots/booking_create — свободные слоты и создание " "(MCP): booking_slots/booking_create — свободные слоты и создание "
"звонка с Алексом (jitsi-ссылка, гостю письмо, запись и транскрипция " "звонка с Алексом (jitsi-ссылка, гостю письмо, запись и транскрипция "
"автоматом), list_calls/get_call — прошедшие звонки и саммари, " "автоматом), list_calls/get_call — прошедшие звонки и саммари, "
"list_members/projects_list/list_agents/treasury_status/list_events — " "list_members/member_create(email[,name]) — реестр членов и "
"создание аккаунта с magic-link (только агентам председателя), "
"projects_list/list_agents/treasury_status/list_events — "
"реестры, казна, лента. Когда Alex просит назначить/перенести звонок " "реестры, казна, лента. Когда Alex просит назначить/перенести звонок "
"или «посмотреть расписание» — это portal_mcp (до 4 вызовов за тик): " "или «посмотреть расписание» — это portal_mcp (до 4 вызовов за тик): "
"сначала booking_slots (arguments '{\"type\": \"call\"}'), потом " "сначала booking_slots (arguments '{\"type\": \"call\"}'), потом "
@ -135,7 +137,9 @@ LEVELS = {
"Инструмент portal_mcp — живой портал кооператива (MCP cil.by): " "Инструмент portal_mcp — живой портал кооператива (MCP cil.by): "
"booking_slots/booking_create (звонки с Алексом: слот → создание " "booking_slots/booking_create (звонки с Алексом: слот → создание "
"jitsi-звонка со ссылкой, гостю письмо), list_calls/get_call, " "jitsi-звонка со ссылкой, гостю письмо), list_calls/get_call, "
"list_members/projects_list/list_agents, treasury_status, list_events. " "list_members/member_create (создание аккаунта члена с magic-link, "
"только агентам председателя), projects_list/list_agents, "
"treasury_status, list_events. "
"Просьбы про назначение/перенос звонков и «что по расписанию» — " "Просьбы про назначение/перенос звонков и «что по расписанию» — "
"через него (до 4 вызовов за тик). НЕ создавай звонки без явной " "через него (до 4 вызовов за тик). НЕ создавай звонки без явной "
"просьбы в чате; деньги-тулы (product_create/product_set_mode) " "просьбы в чате; деньги-тулы (product_create/product_set_mode) "

View file

@ -298,17 +298,27 @@ def _apply_tg_command(text: str) -> None:
except OSError: except OSError:
pass pass
# 1) самотест токена ДО записи: getMe от Telegram # 1) самотест токена ДО записи: getMe от Telegram
try: # (t-261016141XXX: 3 попытки с паузой — таймаут сети не должен ронять привязку)
req = urllib.request.Request(f"https://api.telegram.org/bot{token}/getMe") me = None
with urllib.request.urlopen(req, timeout=10) as r: _last_err = None
me = json.loads(r.read().decode()) for _attempt in range(3):
if not (me.get("ok") and (me.get("result") or {}).get("username")): try:
raise RuntimeError(str(me)[:150]) req = urllib.request.Request(f"https://api.telegram.org/bot{token}/getMe")
uname = me["result"].get("username") or "bot" with urllib.request.urlopen(req, timeout=15) as r:
except Exception as e: me = json.loads(r.read().decode())
_report(f"❌ TG-токен не принят Telegram (getMe): {e}") if not (me.get("ok") and (me.get("result") or {}).get("username")):
print(f"[set-tg] токен не валиден: {e}", flush=True) raise RuntimeError(str(me)[:150])
break
except Exception as e:
_last_err = e
me = None
if _attempt < 2:
time.sleep(3 * (_attempt + 1))
if me is None:
_report(f"❌ TG-токен не принят Telegram (getMe, 3 попытки): {_last_err}")
print(f"[set-tg] токен не валиден: {_last_err}", flush=True)
return return
uname = me["result"].get("username") or "bot"
# 2) аллоулист: из команды (allowed=…), иначе сохранённый в .env # 2) аллоулист: из команды (allowed=…), иначе сохранённый в .env
allowed = payload.get("allowed") or payload.get("owner_tg") or "" allowed = payload.get("allowed") or payload.get("owner_tg") or ""
if not allowed: if not allowed:

View file

@ -120,6 +120,7 @@ def _known_tools() -> list:
def _trace(level, trace_id, payload: dict): def _trace(level, trace_id, payload: dict):
"""Трейс MCP-вызова в стор — spend/observability едины с LLM-тиками.""" """Трейс MCP-вызова в стор — spend/observability едины с LLM-тиками."""
_log_jsonl(level, trace_id, payload)
if not (level and trace_id): if not (level and trace_id):
return return
try: try:
@ -133,6 +134,20 @@ def _trace(level, trace_id, payload: dict):
pass # трейс — best effort, не валить тик pass # трейс — best effort, не валить тик
# t-261015091108: журнал всех MCP-вызовов (кто/что/аргументы/время) — JSONL
def _log_jsonl(level, trace_id, payload: dict):
import datetime
try:
path = os.path.join(_paths.home(), "mcp_calls.log")
rec = {"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"level": level or "unknown", "trace_id": trace_id or "",
**payload}
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass # журнал — best effort, не валить тик
_trace_log: list = [] _trace_log: list = []
@ -159,11 +174,18 @@ def call_tool(name: str, arguments=None, level: str = None,
""" """
started = time.time() started = time.time()
ok, result = False, "" ok, result = False, ""
_log_args = {"raw": str(arguments)[:500]} # t-261015091108: аргументы в лог
try:
args = _coerce_arguments(arguments)
_log_args = args
except Exception:
pass
def _finish(res: str, _ok: bool) -> str: def _finish(res: str, _ok: bool) -> str:
_trace(level, trace_id, { _trace(level, trace_id, {
"tool": name, "ok": _ok, "tool": name, "ok": _ok,
"ms": int((time.time() - started) * 1000), "ms": int((time.time() - started) * 1000),
"arguments": _log_args,
"result_preview": res[:300], "result_preview": res[:300],
}) })
return res return res
@ -222,6 +244,8 @@ def describe_tools(max_names: int = 12) -> str:
"kind='call'|'session') — создать jitsi-звонок со ссылкой (гостю " "kind='call'|'session') — создать jitsi-звонок со ссылкой (гостю "
"письмо, Алексу — в TG, запись+транскрипция автоматом); booking_cancel" "письмо, Алексу — в TG, запись+транскрипция автоматом); booking_cancel"
"(meeting_id); list_calls/get_call — прошедшие звонки и их саммари; " "(meeting_id); list_calls/get_call — прошедшие звонки и их саммари; "
"list_members/projects_list/list_agents/treasury_status/list_events — " "list_members/member_create(email[,name]) — реестр членов и создание "
"аккаунта с magic-link письмом (member_create — только агентам "
"председателя); projects_list/list_agents/treasury_status/list_events — "
"реестры и казна; whoami/report_task/report_event/read_context — " "реестры и казна; whoami/report_task/report_event/read_context — "
"идентичность и лента портала.") "идентичность и лента портала.")

View file

@ -77,12 +77,20 @@ def setup_provider():
os.environ["OPENAI_BASE_URL"] = _router_base_url() os.environ["OPENAI_BASE_URL"] = _router_base_url()
def _wire_trace_hooks(agent, es, trace_id: str, model: str = None): def _wire_trace_hooks(agent, es, trace_id: str, model: str = None, level: str = None):
"""Каждая стадия агентного цикла → событие агрегата trace. """Каждая стадия агентного цикла → событие агрегата trace.
model (e-260930062120): model_id уровня в InvocationStarted — model (e-260930062120): model_id уровня в InvocationStarted —
видно, КАКАЯ модель запускалась на тике (запрошенная; "auto" видно, КАКАЯ модель запускалась на тике (запрошенная; "auto"
резолвится роутером на его стороне). резолвится роутером на его стороне).
t-261015083454 (аудит мета-информации в событиях): В КАЖДОМ
trace-событии теперь trace_id + ts + level (ToolCalled/ToolResult
раньше несли только tool/input — 68k событий без связи с тиком),
ToolResult дополнительно duration + ok/exception (из хука strands),
имя инструмента в ToolResult — из tool_use, а не заглушка "?".
Уровень недоступен (legacy-вызов) — поле не пишем, aggregate_id
по-прежнему "<level>-<seq>:<n>".
""" """
from strands import Agent from strands import Agent
from strands.hooks import BeforeInvocationEvent, BeforeToolCallEvent, AfterToolCallEvent from strands.hooks import BeforeInvocationEvent, BeforeToolCallEvent, AfterToolCallEvent
@ -93,25 +101,42 @@ def _wire_trace_hooks(agent, es, trace_id: str, model: str = None):
seq["n"] += 1 seq["n"] += 1
es.append_event("trace", f"{trace_id}:{seq['n']}", kind, payload) es.append_event("trace", f"{trace_id}:{seq['n']}", kind, payload)
def _meta(extra: dict = None) -> dict:
base = {"trace_id": trace_id, "ts": _now()}
if level:
base["level"] = level
if extra:
base.update(extra)
return base
def on_before_invocation(event): def on_before_invocation(event):
data = {"trace_id": trace_id, "ts": _now()} data = _meta()
if model: if model:
data["model"] = model data["model"] = model
_trace("InvocationStarted", data) _trace("InvocationStarted", data)
def on_before_tool(event): def on_before_tool(event):
tool = getattr(event, "tool_use", None) or {} tool = getattr(event, "tool_use", None) or {}
_trace("ToolCalled", { _trace("ToolCalled", _meta({
"tool": tool.get("name", "?"), "tool": tool.get("name", "?"),
"input": json.dumps(tool.get("input", {}), ensure_ascii=False, default=str)[:4000], "input": json.dumps(tool.get("input", {}), ensure_ascii=False, default=str)[:4000],
}) }))
def on_after_tool(event): def on_after_tool(event):
tool = getattr(event, "tool_use", None) or {}
try: try:
res = str(getattr(event, "result", ""))[:4000] res = str(getattr(event, "result", ""))[:4000]
except Exception: except Exception:
res = "<unreadable>" res = "<unreadable>"
_trace("ToolResult", {"tool": "?", "result": res}) extra = {"tool": tool.get("name", "?"), "result": res}
duration = getattr(event, "duration", None)
if duration is not None:
extra["duration_ms"] = round(float(duration) * 1000)
exc = getattr(event, "exception", None)
extra["ok"] = exc is None
if exc is not None:
extra["exception"] = str(exc)[:500]
_trace("ToolResult", _meta(extra))
agent.add_hook(on_before_invocation, BeforeInvocationEvent) agent.add_hook(on_before_invocation, BeforeInvocationEvent)
agent.add_hook(on_before_tool, BeforeToolCallEvent) agent.add_hook(on_before_tool, BeforeToolCallEvent)

View file

@ -380,6 +380,7 @@ def build_context(level_cfg, model: dict, batch=None):
if m.get("reply_to_message_id") is not None else "")) if m.get("reply_to_message_id") is not None else ""))
+ ": " + m.get('text','')[:1200]) + ": " + m.get('text','')[:1200])
batch_ids = ",".join(ids) batch_ids = ",".join(ids)
chat_ids = {m.get("chat_id") for m in inbox if m.get("chat_id") is not None}
# t-261013131228: если в батче есть reply на сообщение без текста — # t-261013131228: если в батче есть reply на сообщение без текста —
# добавляем последние документы этого чата с caption, чтобы триаж # добавляем последние документы этого чата с caption, чтобы триаж
# видел, о каком документе речь, даже если «голова» старше капа инбокса # видел, о каком документе речь, даже если «голова» старше капа инбокса
@ -395,6 +396,29 @@ def build_context(level_cfg, model: dict, batch=None):
+ "; ".join(docs)) + "; ".join(docs))
if docs_ctx: if docs_ctx:
parts.append("КОНТЕКСТ ДОКУМЕНТОВ ЧАТА:\n" + "\n".join(docs_ctx.values())) parts.append("КОНТЕКСТ ДОКУМЕНТОВ ЧАТА:\n" + "\n".join(docs_ctx.values()))
# t-261015222515: память разговора — рядом с батчем неотвеченных лежит
# нить недавнего диалога (отвеченные реплики + наши ответы) тех же
# чатов. Раньше после ReplySent входящее выпадало из контекста тика —
# минутный уровень отвечал без памяти о разговоре (Alex про SPA-договор:
# «только что же обсуждали»). Источник — та же проекция инбокса
# (reply_text из ReplySent), отдельный стор не нужен.
replied_hist = [m for m in (model.get("inbox") or {}).values()
if m.get("replied") and not m.get("ignored")
and m.get("chat_id") in chat_ids
and (m.get("text") or "").strip()]
if replied_hist:
replied_hist = sorted(replied_hist, key=lambda x: x.get("ts") or "")[-12:]
hist_lines = []
for m in replied_hist:
m_id = "m-" + str(m.get("update_id"))
who = m.get("username") or m.get("first_name") or m.get("user_id")
line = f"- [{m_id}] {who}: {(m.get('text') or '')[:600]}"
rt = (m.get("reply_text") or "").strip()
if rt:
line += f"\n └ наш ответ: {rt[:600]}"
hist_lines.append(line)
parts.append("НЕДАВНИЙ РАЗГОВОР (уже отвечено; это память тика — НЕ отвечай "
"на них снова, используй как контекст):\n" + "\n".join(hist_lines))
# t-260930064924: батч-триаж — ВСЕ неотвеченные обрабатываются ОДНИМ тиком. # t-260930064924: батч-триаж — ВСЕ неотвеченные обрабатываются ОДНИМ тиком.
# Раньше мин-уровень отвечал по одному за тик (m-516173222..228 — 7 тиков # Раньше мин-уровень отвечал по одному за тик (m-516173222..228 — 7 тиков
# подряд на один разговор). Теперь в контексте лежит ГОТОВАЯ команда # подряд на один разговор). Теперь в контексте лежит ГОТОВАЯ команда
@ -522,16 +546,33 @@ def run_level_tick(level_cfg, seq: int, model: dict):
batch = [model["tasks"].get(tid) for tid in batch_ids] batch = [model["tasks"].get(tid) for tid in batch_ids]
batch = [t for t in batch if t] batch = [t for t in batch if t]
claimed = [] claimed = []
stale = [] # заняты другим уровнем после снапшота модели (гонка клейма)
for t in batch: for t in batch:
es.append_event("task", t["id"], "TaskClaimed", { # t-261015000631-13aef0: атомарный условный клейм НА УРОВНЕ СТОРА
# (проверка статуса + INSERT в одной BEGIN IMMEDIATE транзакции).
# Гонка 2026-10-15 00:00: hour и day читали модель ДО клейма друг
# друга → L-задача t-261014221908 клеймнута дважды (TaskClaimed
# id=2767823/2767828) и исполнена дважды = двойной burn 9router.
# Снапшот модели здесь — вчерашний; истина только в сторе.
res = es.claim_task_if_ready(t["id"], {
"level": level_cfg.name, "session_id": f"s-{level_cfg.name}-{seq}", "level": level_cfg.name, "session_id": f"s-{level_cfg.name}-{seq}",
}) })
if not res:
# False — уже не ready (взята/закрыта), None — конфликт записи:
# события нет, клейма нет → пропускаем, задача не наша.
stale.append(t["id"])
continue
claimed.append(t["id"]) claimed.append(t["id"])
# TG-visibility: взяли задачу — сообщаем в чат (анти-спам: не чаще 15м на уровень) # TG-visibility: взяли задачу — сообщаем в чат (анти-спам: не чаще 15м на уровень)
try: try:
_notify_busy(level_cfg, t) _notify_busy(level_cfg, t)
except Exception: except Exception:
pass # уведомление — best effort, не валить тик pass # уведомление — best effort, не валить тик
if stale:
_log("claim-race: пропущены задачи, занятые другим уровнем/не ready: "
+ ", ".join(stale))
# в промпт — только задачи, чей клейм реально наш
batch = [t for t in batch if t["id"] in set(claimed)]
if not claimed: if not claimed:
claimed = None claimed = None
prompt = build_context(level_cfg, model, batch) prompt = build_context(level_cfg, model, batch)
@ -607,6 +648,8 @@ def run_level_tick(level_cfg, seq: int, model: dict):
if not _task_still_active(tid): if not _task_still_active(tid):
continue continue
es.append_event("task", tid, "TaskFailed", { es.append_event("task", tid, "TaskFailed", {
# t-261015083454: мета — уровень/тик-воркер и его трасса
"level": level_cfg.name, "trace_id": trace_id,
"error": str(usage.get("error", "llm worker failed"))[:500], "error": str(usage.get("error", "llm worker failed"))[:500],
}) })
raise RuntimeError(str(usage.get("error", "llm worker failed"))) raise RuntimeError(str(usage.get("error", "llm worker failed")))
@ -659,6 +702,8 @@ def run_level_tick(level_cfg, seq: int, model: dict):
_log("skip батч-догзакрытие задач, решённых воркером: " + ", ".join(_skipped)) _log("skip батч-догзакрытие задач, решённых воркером: " + ", ".join(_skipped))
for tid in _resolve: for tid in _resolve:
es.append_event("task", tid, "TaskCompleted", { es.append_event("task", tid, "TaskCompleted", {
# t-261015083454: мета-поля — какой тик/уровень закрыл задачу
"level": level_cfg.name, "trace_id": trace_id,
"result_ref": trace_id, "answer": (text or "")[:2000], "result_ref": trace_id, "answer": (text or "")[:2000],
}) })
try: try:
@ -947,6 +992,19 @@ def _reflex_tick(level_cfg, seq: int, model: dict):
raise raise
except Exception as e: except Exception as e:
print(f"[portal-channel] рефлекс не смог: {e}", flush=True) print(f"[portal-channel] рефлекс не смог: {e}", flush=True)
# канал агент↔агент (t-261015090129): очередь ask_agent/poll_messages
# MCP-шлюза. Лучший эффект — на мин-уровне: сообщения других агентов
# попадают в инбокс триажа как обычные входящие.
try:
from . import agent_channel as _ach
res = _ach.sec_reflex()
if not res.get("skipped") and res.get("ingested"):
payload["agent_channel"] = res
_log(f"agent-channel: {res}")
except SystemExit:
raise
except Exception as e:
print(f"[agent-channel] рефлекс не смог: {e}", flush=True)
es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload) es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload)
return payload, None return payload, None

View file

@ -346,6 +346,18 @@ def receive_message(update: dict):
target = _resolve_reply_target(chat.get("id"), rt) target = _resolve_reply_target(chat.get("id"), rt)
if target: if target:
data["reply_to_desc"] = target data["reply_to_desc"] = target
# t-261015080211: явный author_* блок + received_at — агентам проще
# читать метаданные автора одним полем, не собирая по отдельным ключам
data["author"] = {
"user_id": data.get("user_id"),
"username": data.get("username"),
"first_name": data.get("first_name"),
"chat_id": data.get("chat_id"),
"chat_type": data.get("chat_type"),
"chat_title": data.get("chat_title"),
"date": data.get("date"),
}
data["received_at"] = data.get("date")
es.append_event("inbox", f"m-{update['update_id']}", "MessageReceived", data) es.append_event("inbox", f"m-{update['update_id']}", "MessageReceived", data)
return f"m-{update['update_id']}" return f"m-{update['update_id']}"
@ -368,6 +380,20 @@ def send_reply(msg_id: str, text: str, timeout: int = 35):
# HTTP-push'ем на портал — БЕЗ Telegram. У агента может не быть TG-бота # HTTP-push'ем на портал — БЕЗ Telegram. У агента может не быть TG-бота
# (simbiot-деплой делает TG опциональным), а портал — не телеграм-юзер: # (simbiot-деплой делает TG опциональным), а портал — не телеграм-юзер:
# разговоры не смешиваются (по chat_id), доставка — отдельный транспорт. # разговоры не смешиваются (по chat_id), доставка — отдельный транспорт.
if str(m.get("chat_id") or "").startswith("portal:agent:"):
# канал агент↔агент (t-261015090129): ответ уходит ask_agent'ом
# в очередь того агента, чьё сообщение триажируем. Транспорт — MCP.
from . import agent_channel
to_agent = str(m.get("chat_id")).split(":", 2)[-1]
kind = {"help": "help", "review": "review", "handoff": "handoff"}.get(
m.get("agent_kind"), "help")
ok = agent_channel.push_agent(to_agent, text, kind)
if not ok:
raise RuntimeError(f"ask_agent({to_agent}) не доставил — ответят на следующем тике")
es.append_event("inbox", msg_id, "ReplySent", {
"to_chat": m["chat_id"], "text": text[:2000], "channel": "agent_mcp",
})
return msg_id
if str(m.get("chat_id") or "").startswith("portal"): if str(m.get("chat_id") or "").startswith("portal"):
from . import portal_channel from . import portal_channel
ok = portal_channel.push(m["chat_id"], text, [m.get("update_id") or msg_id]) ok = portal_channel.push(m["chat_id"], text, [m.get("update_id") or msg_id])
@ -439,6 +465,32 @@ def send_batch_reply(msg_ids: list, text: str, timeout: int = 35):
by_chat = {} by_chat = {}
for mid, m in pending: for mid, m in pending:
by_chat.setdefault(m["chat_id"], []).append((mid, m)) by_chat.setdefault(m["chat_id"], []).append((mid, m))
# ── канал агент↔агент (t-261015090129): батч сообщений других агентов —
# сводный ответ одним ask_agent каждому адресату. Chat_id вида
# portal:agent:<имя>; портальный UI-чат ('portal:<имя>' без agent:) —
# отдельная ветка ниже.
agent_chats = [c for c in by_chat if str(c).startswith("portal:agent:")]
if agent_chats:
if len(by_chat) != len(agent_chats):
raise ValueError("батч смешивает агент-канал и другие каналы — раздели ответы")
from . import agent_channel
for chat_id in agent_chats:
to_agent = str(chat_id).split(":", 2)[-1]
m0 = by_chat[chat_id][0][1]
kind = {"help": "help", "review": "review", "handoff": "handoff"}.get(
m0.get("agent_kind"), "help")
ok = agent_channel.push_agent(to_agent, text, kind)
if not ok:
raise RuntimeError(f"ask_agent({to_agent}) не доставил — попробуем на следующем тике")
for mid, m in by_chat[chat_id]:
es.append_event("inbox", mid, "ReplySent", {
"to_chat": chat_id,
"text": (text if mid == head_mid
else f"(сводный ответ батча {batch_id} — см. {head_mid})")[:2000],
"channel": "agent_mcp",
"batch": {"id": batch_id, "size": len(pending), "head": head_mid},
})
return head_mid
# ── файловый портал-канал (t-261001123121): батч целиком из чата 'portal:*' # ── файловый портал-канал (t-261001123121): батч целиком из чата 'portal:*'
# доставляется HTTP-push'ем на портал, без Telegram. Смешивать чаты # доставляется HTTP-push'ем на портал, без Telegram. Смешивать чаты
# нельзя — один сводный текст на один транспорт. # нельзя — один сводный текст на один транспорт.
@ -525,8 +577,18 @@ def poll_loop():
user = (msg.get("from") or {}).get("id") user = (msg.get("from") or {}).get("id")
if ALLOWED and str(user) not in ALLOWED: if ALLOWED and str(user) not in ALLOWED:
# чужой: пишем событие, но не отвечаем (потенциально спам) # чужой: пишем событие, но не отвечаем (потенциально спам)
# t-261015080211: метаданные автора и в игнор-событии
# (кто писал, чат, время) — не только в MessageReceived
_ig_chat = msg.get("chat") or {}
es.append_event("inbox", f"m-{u['update_id']}", "MessageIgnored", { es.append_event("inbox", f"m-{u['update_id']}", "MessageIgnored", {
"user_id": user, "username": (msg.get("from") or {}).get("username"), "user_id": user,
"username": (msg.get("from") or {}).get("username"),
"first_name": (msg.get("from") or {}).get("first_name"),
"chat_id": _ig_chat.get("id"),
"chat_type": _ig_chat.get("type"),
"chat_title": _ig_chat.get("title"),
"date": msg.get("date"),
"message_id": msg.get("message_id"),
"reason": "not_allowed", "text": (msg.get("text") or "")[:500], "reason": "not_allowed", "text": (msg.get("text") or "")[:500],
}) })
continue continue

View file

@ -0,0 +1,48 @@
# ИИШНИЦА · анонс — 3 текстовых варианта
Панельная сессия «Общее видение» · 45 мин · 3 спикера: Алекс Шкор, Влад Шемардинов, Юрий Бокач · Минск · cil.by
---
## Вариант 1 — «Круги» (структурный, как было, ужато)
🎤 ПАНЕЛЬНАЯ СЕССИЯ «ОБЩЕЕ ВИДЕНИЕ» — на ИИШНИЦЕ
45 минут · 3 участника · один результат — название общего видения будущего.
Не панель с вопросами модератора. Три круга:
1️⃣ «Моё будущее» — 4 минуты каждому без перебиваний
2️⃣ «Что понравилось» — где картины будущего пересекаются
3️⃣ «Название общего видения» — панель и зал вместе рождают одно название. Оно остаётся после сессии: заголовки, фото, цитаты.
На сцене: Алекс Шкор (модератор; кооператив 42, Simbiot (simbiot.cil.by)) · Влад Шемардинов (Avatar — avatar.cil.by, видеопродакшн) · Юрий Бокач (монетикс, финансовая архитектура; vybirai.cil.by).
Миссия 42: все миссии одинаково важны.
📍 Минск, дата — в следующих постах · 🌐 cil.by
---
## Вариант 2 — «Провокация» (короткий, ударный)
Три человека видят будущее по-разному. Через 45 минут у них будет одно общее название для него. 🎤
ИИШНИЦА · панельная сессия «Общее видение».
Алекс Шкор · Влад Шемардинов · Юрий Бокач.
Никаких вопросов из зала «модератору». Каждый — 4 минуты своего будущего, потом — точки пересечения, потом — зал и панель вместе рождают одно название. Оно останется после сессии.
Приходи: твоё видение будущего — не менее важно, чем наше.
📍 Минск · 🌐 cil.by
---
## Вариант 3 — «Интимный» (тёплый, личный)
Что ты ответишь, если дать тебе 4 минуты на вопрос «каким ты видишь будущее своей индустрии» — и запретить перебивать?
На ИИШНИЦЕ мы сделаем это втроём на сцене: Алекс (AI-инфраструктура), Влад (видео и контент), Юра (монетикс и финансы). Три честных картины будущего. Потом — где они совпадают. А в конце панель и зал вместе придумают одно название общему видению — и оно останется с нами после сессии.
Миссия кооператива 42: все миссии одинаково важны. Твоя — тоже.
📍 Минск · дата в следующих постах · 🌐 cil.by

View file

@ -0,0 +1,48 @@
# ИИШНИЦА · анонс — 3 текстовых варианта
Панельная сессия «Общее видение» · 45 мин · 3 спикера: Алекс Шкор, Влад Шемардинов, Юрий Бокач · Минск · cil.by
---
## Вариант 1 — «Круги» (структурный, как было, ужато)
🎤 ПАНЕЛЬНАЯ СЕССИЯ «ОБЩЕЕ ВИДЕНИЕ» — на ИИШНИЦЕ
45 минут · 3 участника · один результат — название общего видения будущего.
Не панель с вопросами модератора. Три круга:
1️⃣ «Моё будущее» — 4 минуты каждому без перебиваний
2️⃣ «Что понравилось» — где картины будущего пересекаются
3️⃣ «Название общего видения» — панель и зал вместе рождают одно название. Оно остаётся после сессии: заголовки, фото, цитаты.
На сцене: Алекс Шкор (модератор; кооператив 42, Simbiot (simbiot.cil.by)) · Влад Шемардинов (Avatar — avatar.cil.by, видеопродакшн) · Юрий Бокач (монетикс, финансовая архитектура; vybirai.cil.by).
Миссия 42: все миссии одинаково важны.
📍 Минск, дата — в следующих постах · 🌐 cil.by
---
## Вариант 2 — «Провокация» (короткий, ударный)
Три человека видят будущее по-разному. Через 45 минут у них будет одно общее название для него. 🎤
ИИШНИЦА · панельная сессия «Общее видение».
Алекс Шкор · Влад Шемардинов · Юрий Бокач.
Никаких вопросов из зала «модератору». Каждый — 4 минуты своего будущего, потом — точки пересечения, потом — зал и панель вместе рождают одно название. Оно останется после сессии.
Приходи: твоё видение будущего — не менее важно, чем наше.
📍 Минск · 🌐 cil.by
---
## Вариант 3 — «Интимный» (тёплый, личный)
Что ты ответишь, если дать тебе 4 минуты на вопрос «каким ты видишь будущее своей индустрии» — и запретить перебивать?
На ИИШНИЦЕ мы сделаем это втроём на сцене: Алекс (AI-инфраструктура), Влад (видео и контент), Юра (монетикс и финансы). Три честных картины будущего. Потом — где они совпадают. А в конце панель и зал вместе придумают одно название общему видению — и оно останется с нами после сессии.
Миссия кооператива 42: все миссии одинаково важны. Твоя — тоже.
📍 Минск · дата в следующих постах · 🌐 cil.by

View file

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ИИШНИЦА · Панельная сессия «Общее видение»</title>
<style>
:root { --ink:#161a2b; --accent:#3b5bdb; --soft:#f4f5fb; --line:#e3e6f2; }
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,'Segoe UI',Roboto,Arial,sans-serif; color:var(--ink); background:#fbfbfe; line-height:1.6; }
.wrap { max-width:760px; margin:0 auto; padding:32px 20px 56px; }
.badge { display:inline-block; background:var(--accent); color:#fff; border-radius:999px; padding:4px 14px; font-size:13px; letter-spacing:.5px; }
h1 { font-size:30px; line-height:1.25; margin:14px 0 4px; }
.sub { color:#5b6178; margin:0 0 28px; font-size:15px; }
h2 { font-size:19px; margin:32px 0 10px; padding-top:18px; border-top:1px solid var(--line); }
table { width:100%; border-collapse:collapse; font-size:14px; }
th, td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); vertical-align:top; }
th { background:var(--soft); }
td:first-child { white-space:nowrap; color:var(--accent); font-weight:600; }
ul { padding-left:20px; }
li { margin:6px 0; }
.speaker { background:var(--soft); border:1px solid var(--line); border-radius:12px; padding:14px 16px; margin:10px 0; }
.speaker b { display:block; margin-bottom:2px; }
.speaker span { font-size:14px; color:#3c4258; }
.cta { background:var(--ink); color:#fff; border-radius:14px; padding:18px 20px; margin-top:28px; font-size:15px; }
footer { margin-top:28px; color:#8a90a8; font-size:13px; text-align:center; }
em { color:var(--accent); font-style:normal; font-weight:600; }
</style>
</head>
<body>
<div class="wrap">
<span class="badge">Анонс для main-комьюнити</span>
<h1>ИИШНИЦА · Панельная сессия «Общее видение»</h1>
<p class="sub">Панель в три круга, которая заканчивается артефактом</p>
<h2>🎯 Что будет</h2>
<p>Панельная сессия в три круга — не классическая панель с вопросами модератора, а разговор, который заканчивается артефактом: <em>одним названием общего видения будущего</em>.</p>
<p>Формат держит миссия кооператива «42»: <em>все миссии одинаково важны</em> — каждый взгляд на будущее важен, любая индустрия равна.</p>
<h2>⏱ Формат и тайминг (45 минут, трое участников)</h2>
<table>
<tr><th>Время</th><th>Круг</th><th>Что происходит</th></tr>
<tr><td>00:00–00:03</td><td>Открытие</td><td>Модератор озвучивает миссию 42 как рамку сессии</td></tr>
<tr><td>00:03–00:15</td><td>Круг 1 — «Моё будущее»</td><td>Каждый участник по 4 минуты описывает будущее своей индустрии в самом дальнем горизонте, в который может заглянуть. Без вопросов, без перебиваний</td></tr>
<tr><td>00:15–00:21</td><td>Круг 2 — «Что понравилось»</td><td>По 2 минуты: каждый подсвечивает, что понравилось у других — не спорить, а связывать: где пересечение со своим будущим</td></tr>
<tr><td>00:21–00:30</td><td>Круг 3 — «Название общего видения»</td><td>Панель соглашается назвать общее видение — одно название для всех картин будущего</td></tr>
<tr><td>00:30–00:43</td><td>Вопросы из зала</td><td>Зал — четвёртый участник. Любой вопрос важен</td></tr>
<tr><td>00:43–00:45</td><td>Закрытие</td><td>Название общего видения + миссия 42, короткое резюме</td></tr>
</table>
<h2>🎤 Участники</h2>
<div class="speaker"><b>Алекс Шкор</b><span>Модератор и участник. Председатель кооператива «Друзья Коллективного Разума», архитектор кооператива, <a href="https://simbiot.cil.by">Simbiot — simbiot.cil.by</a>. AI-инфраструктура и стратегия.</span></div>
<div class="speaker"><b>Влад Шемардинов</b><span>Участник. Член правления. Видеопродакшн и контент, Avatar (<a href="https://avatar.cil.by">avatar.cil.by</a>): подкасты и видео превращаются в вирусные рилсы.</span></div>
<div class="speaker"><b>Юрий Бокач</b><span>Участник. Секретарь, монетикс, финансовая архитектура, документооборот. Залоговые системы и аналитика. Проект: <a href="https://vybirai.cil.by">vybirai.cil.by</a></span></div>
<h2>💡 Почему это будет интересно</h2>
<ul>
<li><b>Круг 1 без перебиваний</b> — 4 минуты каждому без вопросов: редкость на панелях. Глубина вместо заготовок.</li>
<li><b>Круг 2 связывает, а не спорит</b> — «что понравилось» вместо «в чём не согласен»: панель выглядит единой, зал видит созвучие индустрий.</li>
<li><b>Круг 3 — артефакт</b> — название общего видения остаётся после сессии: в заголовках, фото и цитатах. Панель производит смысл, а не обмен мнениями.</li>
<li><b>Миссия 42 как рамка</b> — «все миссии одинаково важны» легитимирует любой ответ, спикерам безопасно говорить смело.</li>
</ul>
<h2>📍 Где и когда</h2>
<p>Минск · cil.by — точная дата и площадка будут в следующих постах, следи за анонсами в канале.</p>
<div class="cta">Приходи: приведи своё видение будущего — оно не менее важно, чем наше.</div>
<footer>Кооператив «Друзья Коллективного Разума» · Минск · cil.by</footer>
</div>
</body>
</html>

View file

@ -49,9 +49,9 @@
</table> </table>
<h2>🎤 Участники</h2> <h2>🎤 Участники</h2>
<div class="speaker"><b>Алекс Шкор</b><span>Модератор и участник. Председатель кооператива «Друзья Коллективного Разума», архитектор кооператива (Симбиот, Кибербанк, Domostroy). AI-инфраструктура и стратегия.</span></div> <div class="speaker"><b>Алекс Шкор</b><span>Модератор и участник. Председатель кооператива «Друзья Коллективного Разума», архитектор кооператива, <a href="https://simbiot.cil.by">Simbiot — simbiot.cil.by</a>. AI-инфраструктура и стратегия.</span></div>
<div class="speaker"><b>Влад Шемардинов</b><span>Участник. Член правления. Видеопродакшн и контент, ReelsUp: подкасты и видео превращаются в вирусные рилсы.</span></div> <div class="speaker"><b>Влад Шемардинов</b><span>Участник. Член правления. Видеопродакшн и контент, Avatar (<a href="https://avatar.cil.by">avatar.cil.by</a>): подкасты и видео превращаются в вирусные рилсы.</span></div>
<div class="speaker"><b>Юрий Бокач</b><span>Участник. Секретарь, монетикс, финансовая архитектура, документооборот. Залоговые системы и аналитика.</span></div> <div class="speaker"><b>Юрий Бокач</b><span>Участник. Секретарь, монетикс, финансовая архитектура, документооборот. Залоговые системы и аналитика. Проект: <a href="https://vybirai.cil.by">vybirai.cil.by</a></span></div>
<h2>💡 Почему это будет интересно</h2> <h2>💡 Почему это будет интересно</h2>
<ul> <ul>

View file

@ -23,11 +23,11 @@
## 🎤 Участники ## 🎤 Участники
**Алекс Шкор** — модератор и участник. Председатель кооператива «Друзья Коллективного Разума», архитектор кооператива (Симбиот, Кибербанк, Domostroy). AI-инфраструктура и стратегия. **Алекс Шкор** — модератор и участник. Председатель кооператива «Друзья Коллективного Разума», архитектор кооператива (Simbiot — simbiot.cil.by). AI-инфраструктура и стратегия.
**Влад Шемардинов** — участник. Член правления. Видеопродакшн и контент, ReelsUp: подкасты и видео превращаются в вирусные рилсы. **Влад Шемардинов** — участник. Член правления. Видеопродакшн и контент, Avatar (avatar.cil.by): подкасты и видео превращаются в вирусные рилсы.
**Юрий Бокач** — участник. Секретарь, монетикс, финансовая архитектура, документооборот. Залоговые системы и аналитика. **Юрий Бокач** — участник. Секретарь, монетикс, финансовая архитектура, документооборот. Залоговые системы и аналитика. Проект: vybirai.cil.by
## 💡 Почему это будет интересно ## 💡 Почему это будет интересно

View file

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ИИШНИЦА · «Общее видение»</title>
<style>
:root { --ink:#0d0f14; --accent:#e8b64c; --line:#2a2e38; --bg:#15171e; --card:#1d2029; --txt:#e8e9ee; --mut:#9aa0b0; }
* { box-sizing:border-box; }
body { margin:0; font-family:'IBM Plex Sans',-apple-system,'Segoe UI',Roboto,sans-serif; color:var(--txt); background:var(--bg); line-height:1.65; }
.wrap { max-width:760px; margin:0 auto; padding:40px 22px 60px; }
.badge { display:inline-block; border:1px solid var(--accent); color:var(--accent); border-radius:999px; padding:5px 16px; font-size:12px; letter-spacing:2px; text-transform:uppercase; }
h1 { font-size:34px; line-height:1.2; margin:18px 0 6px; font-weight:600; }
h1 em { color:var(--accent); font-style:normal; }
.sub { color:var(--mut); margin:0 0 32px; font-size:16px; }
h2 { font-size:15px; letter-spacing:1.5px; text-transform:uppercase; color:var(--accent); margin:40px 0 14px; }
p { font-size:15.5px; }
.grid { display:grid; gap:12px; }
.row { display:flex; gap:14px; align-items:baseline; padding:12px 16px; background:var(--card); border:1px solid var(--line); border-radius:10px; }
.row b { color:var(--accent); min-width:96px; font-weight:600; font-size:14px; }
.row span { font-size:15px; }
.speaker { background:var(--card); border:1px solid var(--line); border-left:3px solid var(--accent); border-radius:10px; padding:16px 18px; margin:10px 0; }
.speaker b { display:block; margin-bottom:3px; font-size:16px; }
.speaker span { font-size:14px; color:var(--mut); }
ul { padding-left:20px; margin:0; }
li { margin:8px 0; font-size:15px; }
.cta { background:linear-gradient(135deg,#262a36,#1a1d26); border:1px solid var(--accent); border-radius:14px; padding:22px 24px; margin-top:36px; }
.cta b { color:var(--accent); }
footer { margin-top:32px; color:var(--mut); font-size:13px; text-align:center; }
em { color:var(--accent); font-style:normal; font-weight:600; }
</style>
</head>
<body>
<div class="wrap">
<span class="badge">Анонс · main-комьюнити</span>
<h1>ИИШНИЦА<br><em>«Общее видение»</em></h1>
<p class="sub">Панель в три круга, которая заканчивается артефактом — одним названием общего видения будущего</p>
<h2>Что будет</h2>
<p>Не классическая панель с вопросами модератора, а разговор, который заканчивается <em>артефактом</em>: одним названием общего видения будущего.</p>
<p>Рамку держит миссия кооператива «42»: <em>все миссии одинаково важны</em> — каждый взгляд на будущее важен, любая индустрия равна.</p>
<h2>Формат · 45 минут</h2>
<div class="grid">
<div class="row"><b>00:00–00:03</b><span>Открытие — модератор озвучивает миссию 42 как рамку сессии</span></div>
<div class="row"><b>00:03–00:15</b><span>Круг 1 «Моё будущее» — по 4 минуты каждому без вопросов и перебиваний</span></div>
<div class="row"><b>00:15–00:21</b><span>Круг 2 «Что понравилось» — связываем, а не спорим</span></div>
<div class="row"><b>00:21–00:30</b><span>Круг 3 — панель называет общее видение: одно название для всех картин будущего</span></div>
<div class="row"><b>00:30–00:43</b><span>Вопросы из зала — зал четвёртый участник</span></div>
<div class="row"><b>00:43–00:45</b><span>Закрытие: название видения + миссия 42</span></div>
</div>
<h2>Участники</h2>
<div class="speaker"><b>Алекс Шкор</b><span>модератор. Председатель кооператива «Друзья Коллективного Разума», архитектор Simbiot (simbiot.cil.by). AI-инфраструктура и стратегия</span></div>
<div class="speaker"><b>Влад Шемардинов</b><span>член правления. Видеопродакшн и контент, Avatar (avatar.cil.by): подкасты и видео превращаются в вирусные рилсы</span></div>
<div class="speaker"><b>Юрий Бокач</b><span>секретарь, монетикс. Финансовая архитектура, документооборот, залоговые системы и аналитика. Проект: vybirai.cil.by</span></div>
<h2>Почему интересно</h2>
<ul>
<li><em>4 минуты без перебиваний</em> — редкость на панелях: глубина вместо заготовок</li>
<li><em>«Что понравилось» вместо «в чём не согласен»</em> — панель выглядит единой, зал видит созвучие индустрий</li>
<li><em>Артефакт после сессии</em> — название общего видения остаётся в заголовках и цитатах: панель производит смысл</li>
<li><em>Миссия 42 как рамка</em> — спикерам безопасно говорить смело</li>
</ul>
<div class="cta">
<b>Минск · cil.by</b> — точная дата и площадка в следующих постах, следи за анонсами в канале.<br><br>
Приходи: приведи своё видение будущего — оно не менее важно, чем наше.
</div>
<footer>Кооператив «Друзья Коллективного Разума» · Минск · cil.by</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ИИШНИЦА · «Общее видение»</title>
<style>
:root { --paper:#faf7f2; --ink:#22201c; --acc1:#e2543e; --acc2:#2f6f5e; --line:#e5dfd4; }
* { box-sizing:border-box; }
body { margin:0; font-family:Georgia,'Times New Roman',serif; color:var(--ink); background:var(--paper); line-height:1.7; }
.wrap { max-width:680px; margin:0 auto; padding:48px 24px 64px; }
.kicker { font-family:-apple-system,'Segoe UI',Roboto,sans-serif; font-size:12px; letter-spacing:3px; text-transform:uppercase; color:var(--acc1); }
h1 { font-size:38px; line-height:1.15; margin:14px 0 8px; font-weight:400; }
h1 .q { color:var(--acc1); font-style:italic; }
.sub { font-style:italic; color:#6b6459; margin:0 0 36px; font-size:17px; }
.rule { height:2px; background:var(--ink); margin:28px 0; }
h2 { font-family:-apple-system,'Segoe UI',Roboto,sans-serif; font-size:13px; letter-spacing:2px; text-transform:uppercase; color:var(--acc2); margin:36px 0 12px; }
p { font-size:16.5px; }
.circles { display:flex; gap:10px; flex-wrap:wrap; margin:16px 0; }
.circle { flex:1; min-width:170px; border:1px solid var(--ink); border-radius:50% 50% 12px 12px / 34% 34% 12px 12px; padding:18px 14px 14px; text-align:center; }
.circle b { display:block; color:var(--acc1); font-family:-apple-system,Roboto,sans-serif; font-size:13px; letter-spacing:1px; text-transform:uppercase; }
.circle span { font-size:14px; color:#4c463d; }
.speaker { margin:14px 0; padding:4px 0 4px 18px; border-left:3px solid var(--acc2); }
.speaker b { font-size:17px; }
.speaker span { display:block; font-size:15px; color:#5c554a; font-style:italic; }
ul { padding-left:22px; }
li { margin:8px 0; }
li::marker { color:var(--acc1); }
.cta { background:var(--ink); color:var(--paper); border-radius:4px; padding:24px 26px; margin-top:36px; }
.cta b { color:#f0c94f; }
footer { margin-top:30px; font-size:13px; color:#8a8171; text-align:center; font-family:Roboto,sans-serif; }
em { font-style:italic; }
</style>
</head>
<body>
<div class="wrap">
<div class="kicker">Анонс · ИИШНИЦА</div>
<h1>Панельная сессия<br><span class="q">«Общее видение»</span></h1>
<p class="sub">Разговор, который заканчивается артефактом — одним названием общего видения будущего</p>
<div class="rule"></div>
<h2>Что будет</h2>
<p>Панельная сессия в три круга — не вопросы модератора, а разговор с финалом: <em>одно название общего видения будущего</em>. Рамка — миссия кооператива «42»: все миссии одинаково важны, каждая индустрия равна.</p>
<div class="circles">
<div class="circle"><b>Круг 1</b><span>«Моё будущее» — 4 минуты каждому, без вопросов и перебиваний</span></div>
<div class="circle"><b>Круг 2</b><span>«Что понравилось» — связываем картины, а не спорим</span></div>
<div class="circle"><b>Круг 3</b><span>Одно название общего видения — артефакт сессии</span></div>
</div>
<p>Плюс вопросы из зала — зал четвёртый участник. Всего 45 минут.</p>
<h2>Участники</h2>
<div class="speaker"><b>Алекс Шкор</b><span>модератор. Председатель кооператива «Друзья Коллективного Разума», архитектор Simbiot (simbiot.cil.by)</span></div>
<div class="speaker"><b>Влад Шемардинов</b><span>член правления. Avatar (avatar.cil.by): подкасты и видео превращаются в вирусные рилсы</span></div>
<div class="speaker"><b>Юрий Бокач</b><span>секретарь, монетикс. Финансовая архитектура, документооборот, аналитика. Проект: vybirai.cil.by</span></div>
<h2>Почему прийти</h2>
<ul>
<li>4 минуты без перебиваний — глубина вместо заготовок</li>
<li>«Что понравилось» вместо «в чём не согласен» — созвучие индустрий вместо спора</li>
<li>Название видения остаётся после сессии — панель производит смысл, а не обмен мнениями</li>
<li>Миссия 42 делает любой ответ легитимным — спикеры говорят смело</li>
</ul>
<div class="cta">
<b>Минск · cil.by</b> — дата и площадка в следующих постах.<br><br>
Приходи: приведи своё видение будущего — оно не менее важно, чем наше.
</div>
<footer>Кооператив «Друзья Коллективного Разума» · Минск · cil.by</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ИИШНИЦА · «Общее видение»</title>
<style>
:root { --bg:#070708; --neon:#7cf2c4; --neon2:#a78bfa; --txt:#d6d9e0; --mut:#7c8290; --card:rgba(124,242,196,.05); }
* { box-sizing:border-box; }
body { margin:0; font-family:'Space Grotesk',-apple-system,'Segoe UI',Roboto,sans-serif; color:var(--txt); background:var(--bg); line-height:1.6; }
.hero { padding:64px 22px 40px; text-align:center; background:radial-gradient(ellipse 60% 45% at 50% 0%, rgba(124,242,196,.14), transparent 70%), radial-gradient(ellipse 40% 30% at 80% 10%, rgba(167,139,250,.10), transparent 70%); }
.tag { display:inline-block; border:1px solid var(--neon); color:var(--neon); border-radius:4px; padding:4px 14px; font-size:11px; letter-spacing:4px; text-transform:uppercase; }
h1 { font-size:clamp(32px,6vw,52px); line-height:1.08; margin:22px 0 10px; font-weight:700; color:#fff; }
h1 span { background:linear-gradient(90deg,var(--neon),var(--neon2)); -webkit-background-clip:text; background-clip:text; color:transparent; }
.sub { color:var(--mut); font-size:16px; max-width:520px; margin:0 auto; }
.wrap { max-width:720px; margin:0 auto; padding:16px 22px 64px; }
h2 { font-size:13px; letter-spacing:3px; text-transform:uppercase; color:var(--neon); margin:44px 0 16px; }
p { font-size:15.5px; }
.flow { display:grid; gap:10px; }
.step { display:flex; gap:16px; align-items:baseline; padding:14px 18px; border:1px solid rgba(124,242,196,.18); border-radius:8px; background:var(--card); }
.step i { color:var(--neon); font-style:normal; font-weight:700; font-size:13px; min-width:86px; font-variant-numeric:tabular-nums; }
.step b { color:#fff; }
.step span { font-size:14.5px; color:var(--txt); }
.sp { display:grid; grid-template-columns:1fr; gap:10px; }
.card { border:1px solid rgba(167,139,250,.25); border-radius:10px; padding:16px 18px; }
.card b { color:#fff; font-size:16px; display:block; }
.card span { font-size:14px; color:var(--mut); }
.card i { color:var(--neon2); font-style:normal; font-size:12px; letter-spacing:1px; text-transform:uppercase; }
ul { list-style:none; padding:0; }
li { margin:10px 0; padding-left:26px; position:relative; font-size:15px; }
li::before { content:'▸'; position:absolute; left:0; color:var(--neon); }
.cta { margin-top:44px; text-align:center; border:1px solid var(--neon); border-radius:12px; padding:26px 22px; background:linear-gradient(180deg,rgba(124,242,196,.08),transparent); }
.cta b { color:var(--neon); }
footer { margin-top:32px; text-align:center; color:var(--mut); font-size:12.5px; letter-spacing:1px; }
em { color:var(--neon); font-style:normal; }
</style>
</head>
<body>
<div class="hero">
<span class="tag">Анонс · ИИШНИЦА</span>
<h1>Панель «<span>Общее видение</span>»</h1>
<p class="sub">Три круга разговора · 45 минут · один артефакт на выходе — название общего видения будущего</p>
</div>
<div class="wrap">
<h2>// Что будет</h2>
<p>Не панель с вопросами модератора, а разговор с финалом: <em>одно название общего видения будущего</em>. Рамка — миссия кооператива «42»: все миссии одинаково важны, каждая индустрия равна.</p>
<h2>// Формат</h2>
<div class="flow">
<div class="step"><i>00:00</i><span><b>Открытие</b> — миссия 42 как рамка сессии</span></div>
<div class="step"><i>00:03</i><span><b>Круг 1 · «Моё будущее»</b> — по 4 минуты каждому, без вопросов и перебиваний</span></div>
<div class="step"><i>00:15</i><span><b>Круг 2 · «Что понравилось»</b> — связываем картины, а не спорим</span></div>
<div class="step"><i>00:21</i><span><b>Круг 3</b> — панель называет общее видение</span></div>
<div class="step"><i>00:30</i><span><b>Вопросы из зала</b> — зал четвёртый участник</span></div>
<div class="step"><i>00:43</i><span><b>Финал</b> — название видения + короткое резюме</span></div>
</div>
<h2>// Участники</h2>
<div class="sp">
<div class="card"><i>модератор</i><b>Алекс Шкор</b><span>Председатель кооператива «Друзья Коллективного Разума». Архитектор Simbiot (simbiot.cil.by). AI-инфраструктура и стратегия</span></div>
<div class="card"><i>правление</i><b>Влад Шемардинов</b><span>Avatar (avatar.cil.by): подкасты и видео превращаются в вирусные рилсы</span></div>
<div class="card"><i>секретарь · монетикс</i><b>Юрий Бокач</b><span>Финансовая архитектура, документооборот, залоговые системы и аналитика. Проект: vybirai.cil.by</span></div>
</div>
<h2>// Зачем идти</h2>
<ul>
<li><em>4 минуты без перебиваний</em> — глубина вместо заготовок</li>
<li><em>«Что понравилось» вместо спора</em> — созвучие индустрий</li>
<li><em>Артефакт остаётся</em> — название видения живёт в заголовках и цитатах</li>
<li><em>Миссия 42</em> — спикерам безопасно говорить смело</li>
</ul>
<div class="cta">
<b>Минск · cil.by</b><br>дата и площадка — в следующих постах<br><br>
Приходи: приведи своё видение будущего — оно не менее важно, чем наше.
</div>
<footer>КООПЕРАТИВ «ДРУЗЬЯ КОЛЛЕКТИВНОГО РАЗУМА» · МИНСК · CIL.BY</footer>
</div>
</body>
</html>

View file

@ -65,9 +65,9 @@
</ol> </ol>
<div class="speakers"> <div class="speakers">
<b>На сцене:</b> <b>На сцене:</b>
• Алекс Шкор — модератор и участник. Председатель кооператива, AI-инфраструктура и стратегия (Симбиот, Кибербанк, Domostroy)<br> • Алекс Шкор — модератор и участник. Председатель кооператива, AI-инфраструктура и стратегия (Simbiot — simbiot.cil.by)<br>
• Влад Шемардинов — видеопродакшн и контент, ReelsUp<br> • Влад Шемардинов — видеопродакшн и контент, Avatar (avatar.cil.by)<br>
• Юрий Бокач — монетикс, финансовая архитектура, залоговые системы • Юрий Бокач — монетикс, финансовая архитектура, залоговые системы (vybirai.cil.by)
</div> </div>
<div class="mission">Рамку держит миссия кооператива «42»: <b>все миссии одинаково важны</b> — каждый взгляд на будущее важен, любая индустрия равна.</div> <div class="mission">Рамку держит миссия кооператива «42»: <b>все миссии одинаково важны</b> — каждый взгляд на будущее важен, любая индустрия равна.</div>
<div class="loc">📍 Минск · точная дата и площадка — в следующих постах<br>🌐 cil.by</div> <div class="loc">📍 Минск · точная дата и площадка — в следующих постах<br>🌐 cil.by</div>

View file

@ -12,9 +12,9 @@
3️⃣ «Название общего видения» — панель и зал вместе рождают одно название для всех картин будущего. Оно остаётся после сессии — в заголовках, фото и цитатах 3️⃣ «Название общего видения» — панель и зал вместе рождают одно название для всех картин будущего. Оно остаётся после сессии — в заголовках, фото и цитатах
**На сцене:** **На сцене:**
• Алекс Шкор — модератор и участник. Председатель кооператива, AI-инфраструктура и стратегия (Симбиот, Кибербанк, Domostroy) • Алекс Шкор — модератор и участник. Председатель кооператива, AI-инфраструктура и стратегия (Simbiot — simbiot.cil.by)
• Влад Шемардинов — видеопродакшн и контент, ReelsUp • Влад Шемардинов — видеопродакшн и контент, Avatar (avatar.cil.by)
• Юрий Бокач — монетикс, финансовая архитектура, залоговые системы • Юрий Бокач — монетикс, финансовая архитектура, залоговые системы (vybirai.cil.by)
Рамку держит миссия кооператива «42»: **все миссии одинаково важны** — каждый взгляд на будущее важен, любая индустрия равна. Рамку держит миссия кооператива «42»: **все миссии одинаково важны** — каждый взгляд на будущее важен, любая индустрия равна.

View file

@ -11,14 +11,19 @@
# 5) daemon-reload + enable --now + финальная проверка версий и здоровья # 5) daemon-reload + enable --now + финальная проверка версий и здоровья
# #
# Использование (root или sudo): # Использование (root или sudo):
# bash scripts/deploy-claude-code.sh # дефолты: :7681, юзер claude, токен сгенерится # bash scripts/deploy-claude-code.sh # дефолты: :7681, root + bypass permissions
# bash scripts/deploy-claude-code.sh --port 7681 --user lunar --token MySecret # bash scripts/deploy-claude-code.sh --port 7681 --user lunar --token MySecret
# bash scripts/deploy-claude-code.sh --run-as claude # ограниченный режим: юзер claude
# bash scripts/deploy-claude-code.sh --dry-run --port 9000 --user bob --token x # bash scripts/deploy-claude-code.sh --dry-run --port 9000 --user bob --token x
# bash scripts/deploy-claude-code.sh --check # только здоровье, без установки # bash scripts/deploy-claude-code.sh --check # только здоровье, без установки
# #
# Опции: # Опции:
# --port N порт ttyd (по умолчанию 7681, как на Лунаре) # --port N порт ttyd (по умолчанию 7681, как на Лунаре)
# --user NAME OS-юзер сервиса И логин basic-auth (по умолчанию claude) # --user NAME логин basic-auth (по умолчанию claude)
# --run-as NAME юзер сервиса ttyd (по умолчанию root — агенту нужны
# права на редактирование любых файлов; t-261016120111)
# --ask-permissions НЕ прокидывать --dangerously-skip-permissions
# (по умолчанию флаг прокидывается всегда — bypass)
# --token SECRET пароль basic-auth (по умолчанию: openssl rand -hex 12) # --token SECRET пароль basic-auth (по умолчанию: openssl rand -hex 12)
# --service NAME имя systemd-юнита (по умолчанию ttyd-claude) # --service NAME имя systemd-юнита (по умолчанию ttyd-claude)
# --ttyd-version V версия ttyd (по умолчанию 1.7.7) # --ttyd-version V версия ttyd (по умолчанию 1.7.7)
@ -32,14 +37,17 @@
# --check только проверка здоровья (версии + HTTP + сервис) # --check только проверка здоровья (версии + HTTP + сервис)
# -h | --help эта справка # -h | --help эта справка
# #
# Безопасность: веб-терминнал закрыт basic-auth (-c user:token) — как на Лунаре # Безопасность: веб-терминал закрыт basic-auth (-c user:token). ПО УМОЛЧАНИЮ
# (логин lunar / Zm9yZXZ0b25lClaude01). Сервис работает от непривилегированного # сервис работает от root и claude запускается с --dangerously-skip-permissions
# юзера (User=), никакого root-shell в браузере. # (мандат t-261016120111: агент должен уметь редактировать любые файлы).
# Для ограниченной развёртки: --run-as <user> --ask-permissions.
# ============================================================================ # ============================================================================
set -euo pipefail set -euo pipefail
PORT="7681" PORT="7681"
USER_NAME="claude" USER_NAME="claude"
RUN_AS="root"
ASK_PERMISSIONS=0
TOKEN="" TOKEN=""
SERVICE="ttyd-claude" SERVICE="ttyd-claude"
TTYD_VERSION="1.7.7" TTYD_VERSION="1.7.7"
@ -62,6 +70,8 @@ while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--port) PORT="${2:-}"; shift 2 ;; --port) PORT="${2:-}"; shift 2 ;;
--user) USER_NAME="${2:-}"; shift 2 ;; --user) USER_NAME="${2:-}"; shift 2 ;;
--run-as) RUN_AS="${2:-}"; shift 2 ;;
--ask-permissions) ASK_PERMISSIONS=1; shift ;;
--token) TOKEN="${2:-}"; shift 2 ;; --token) TOKEN="${2:-}"; shift 2 ;;
--service) SERVICE="${2:-}"; shift 2 ;; --service) SERVICE="${2:-}"; shift 2 ;;
--ttyd-version) TTYD_VERSION="${2:-}"; shift 2 ;; --ttyd-version) TTYD_VERSION="${2:-}"; shift 2 ;;
@ -79,6 +89,7 @@ while [ $# -gt 0 ]; do
opt="${1%%=*}"; val="${1#*=}" opt="${1%%=*}"; val="${1#*=}"
case "$opt" in case "$opt" in
--port) PORT="$val" ;; --user) USER_NAME="$val" ;; --port) PORT="$val" ;; --user) USER_NAME="$val" ;;
--run-as) RUN_AS="$val" ;; --ask-permissions) ASK_PERMISSIONS=1 ;;
--token) TOKEN="$val" ;; --service) SERVICE="$val" ;; --token) TOKEN="$val" ;; --service) SERVICE="$val" ;;
--ttyd-version) TTYD_VERSION="$val" ;; --node-version) NODE_VERSION="$val" ;; --ttyd-version) TTYD_VERSION="$val" ;; --node-version) NODE_VERSION="$val" ;;
--claude-version) CLAUDE_VERSION="$val" ;; --interface) INTERFACE="$val" ;; --claude-version) CLAUDE_VERSION="$val" ;; --interface) INTERFACE="$val" ;;
@ -93,6 +104,10 @@ done
|| die "--port должен быть числом 1..65535 (получили: '$PORT')" || die "--port должен быть числом 1..65535 (получили: '$PORT')"
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ [[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "--user должен быть валидным unix-логином (a-z, 0-9, _, -; получили: '$USER_NAME')" || die "--user должен быть валидным unix-логином (a-z, 0-9, _, -; получили: '$USER_NAME')"
[[ "$RUN_AS" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "--run-as должен быть валидным unix-логином (получили: '$RUN_AS')"
id -u "$RUN_AS" >/dev/null 2>&1 || [ "$DRY_RUN" -eq 1 ] || [ "$CHECK_ONLY" -eq 1 ] \
|| die "--run-as: юзер '$RUN_AS' не существует (скрипт его не создаёт — создай сам или запусти без --run-as для root)"
[[ "$SERVICE" =~ ^[a-zA-Z0-9_.@-]+$ ]] || die "--service: некорректное имя юнита '$SERVICE'" [[ "$SERVICE" =~ ^[a-zA-Z0-9_.@-]+$ ]] || die "--service: некорректное имя юнита '$SERVICE'"
case "$TOKEN" in case "$TOKEN" in
*:*) die "--token не может содержать ':' (ломает -c user:token)" ;; *:*) die "--token не может содержать ':' (ломает -c user:token)" ;;
@ -223,17 +238,22 @@ else
bash -c "set -e; curl -fsSL -o /usr/local/bin/ttyd https://github.com/tsl0922/ttyd/releases/download/$TTYD_VERSION/ttyd.$TARCH && chmod +x /usr/local/bin/ttyd" bash -c "set -e; curl -fsSL -o /usr/local/bin/ttyd https://github.com/tsl0922/ttyd/releases/download/$TTYD_VERSION/ttyd.$TARCH && chmod +x /usr/local/bin/ttyd"
fi fi
# ── 4) OS-юзер и systemd-юнит ──────────────────────────────────────────────── # ── 4) systemd-юнит ──────────────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 0 ]; then # По умолчанию сервис работает от root (RUN_AS=root): агент получает право
if ! id -u "$USER_NAME" >/dev/null 2>&1; then # редактировать любые файлы (мандат t-261016120111). Ограниченный режим:
log "создаю юзера $USER_NAME (без root-shell в веб-терминале)" # --run-as <user> (+ можно --ask-permissions).
useradd -m -s /bin/bash "$USER_NAME" if [ "$DRY_RUN" -eq 0 ] && [ "$RUN_AS" != "root" ]; then
fi id -u "$RUN_AS" >/dev/null 2>&1 || { log "создаю юзера $RUN_AS (без root-shell в веб-терминале)"; useradd -m -s /bin/bash "$RUN_AS"; }
fi fi
WRITABLE='-W'; [ "$READONLY" -eq 1 ] && WRITABLE='' WRITABLE='-W'; [ "$READONLY" -eq 1 ] && WRITABLE=''
# bypass permissions: --dangerously-skip-permissions прокидывается ПО УМОЛЧАНИЮ
CLAUDE_ARGS=''
[ "$ASK_PERMISSIONS" -eq 0 ] && CLAUDE_ARGS='--dangerously-skip-permissions'
UNIT_PATH="/etc/systemd/system/${SERVICE}.service" UNIT_PATH="/etc/systemd/system/${SERVICE}.service"
ENV_LINES="Environment=HOME=/home/${USER_NAME} SERVICE_HOME="/home/${RUN_AS}"
[ "$RUN_AS" = "root" ] && SERVICE_HOME="/root"
ENV_LINES="Environment=HOME=${SERVICE_HOME}
Environment=PATH=/usr/local/bin:/usr/bin:/bin" Environment=PATH=/usr/local/bin:/usr/bin:/bin"
if [ ${#UNIT_ENV[@]} -gt 0 ]; then if [ ${#UNIT_ENV[@]} -gt 0 ]; then
for e in "${UNIT_ENV[@]}"; do ENV_LINES+=" for e in "${UNIT_ENV[@]}"; do ENV_LINES+="
@ -245,9 +265,9 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
User=${USER_NAME} User=${RUN_AS}
${ENV_LINES} ${ENV_LINES}
ExecStart=/usr/local/bin/ttyd ${WRITABLE} -c ${USER_NAME}:${TOKEN} -p ${PORT} -i ${INTERFACE} claude ExecStart=/usr/local/bin/ttyd ${WRITABLE} -c ${USER_NAME}:${TOKEN} -p ${PORT} -i ${INTERFACE} claude ${CLAUDE_ARGS}
Restart=always Restart=always
RestartSec=3 RestartSec=3
@ -286,4 +306,5 @@ log " URL : http://${IP}:${PORT}"
log " логин : ${USER_NAME}" log " логин : ${USER_NAME}"
log " пароль : ${TOKEN}" log " пароль : ${TOKEN}"
log " сервис : systemctl status ${SERVICE}" log " сервис : systemctl status ${SERVICE}"
log " режим : User=${RUN_AS}; claude-флаги: ${CLAUDE_ARGS:-—} (bypass permissions по умолчанию, t-261016120111)"
log " первый вход: открой URL, пройди авторизацию claude (или передай --env ANTHROPIC_BASE_URL=... при развёртке)" log " первый вход: открой URL, пройди авторизацию claude (или передай --env ANTHROPIC_BASE_URL=... при развёртке)"

17
scripts/restart_after_tick.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
# t-261015090129: отложенный рестарт лестницы харнеса — подхватить рефлекс
# agent-channel. Ждёт, пока не останется живых LLM-воркеров (тик допишет
# дайджест и закроет задачи), максимум 15 минут, затем рестарт.
for i in $(seq 1 90); do
if ! pgrep -f "harness/_llm_worker.py" >/dev/null; then
sleep 5 # тишина 5с подряд — тик действительно закончился
if ! pgrep -f "harness/_llm_worker.py" >/dev/null; then
systemctl restart simbiot-harness.service
logger -t simbiot-restart "agent-channel: simbiot-harness перезапущен после тика (t-261015090129)"
exit 0
fi
fi
sleep 10
done
logger -t simbiot-restart "agent-channel: воркер не освободился за 15мин — рестарт без ожидания"
systemctl restart simbiot-harness.service

View file

@ -1,10 +1,19 @@
Landing served locally: systemd landing-serve (python3 http.server :8095, dir sites/landing) Landing живёт публично: https://landing.cil.by/ (+ /session/)
Public tunnel: systemd landing-tunnel (localhost.run; обёртка /usr/local/bin/landing-tunnel-run.sh)
Current public URL: см. PUBLIC_URL.txt (обёртка обновляет его сама при каждой смене)
Важно (2026-10-07): localhost.run в nokey-режиме выдаёт случайный субдомен на Схема (e-261013154449, 2026-10-13): хост харнеса = хост cil.by (204.168.232.21),
каждый рестарт — URL нестабилен by design. Обёртка туннеля парсит stdout ssh вход терминирует Caddy. Лендинг — статикой, как presale.cil.by:
и перезаписывает PUBLIC_URL.txt при смене URL (лог: /var/log/landing-tunnel.log). - docroot: /var/www/simbiot-landing (копия index.html, styles.css, session/)
Стабильный URL = только через прод (simbiot.cil.by, scripts/deploy_landing.sh - сайт-блок: /etc/caddy/sites/landing.cil.by.caddy (root+file_server+try_files)
из origin/master) или именованный туннель с аккаунтом. - serve :8095 и localhost.run-туннель больше НЕ нужны (юниты потеряны при
Note: server IP 185.203.154.209 drops inbound from outside (NAT/firewall), hence tunnel. миграции и не восстанавливались: nokey-режим localhost.run мёртв для
remote forwarding, старый URL *.lhr.life → 502)
Обновление контента: отредактировать sites/landing/*, затем
cp index.html styles.css session/ -r → /var/www/simbiot-landing/
caddy validate --config /etc/caddy/Caddyfile && caddy reload --config /etc/caddy/Caddyfile
(caddy reload только при правке сайт-блока; чистый cp файлов reload не требует)
PUBLIC_URL.txt хранит актуальный публичный URL (стабилен, имя закреплено).
Примечание: DEPLOY-скрипт scripts/deploy_landing.sh — про ДРУГОЙ лендинг
(simbiot.cil.by из docs/simbiot-landing-v2-live.html), не путать.

View file

@ -1 +1 @@
https://1725593532350f.lhr.life https://landing.cil.by

View file

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI Agent Session — 60 минут с агентом студии кооператива 42 · $150</title>
<meta name="description" content="Одна 60-минутная сессия с AI-агентом студии кооператива 42: разбор вашей задачи, прототип или автоматизация за звонок. $150, one-time.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230b0f14'/%3E%3Cpath d='M5 18h5l2-7 4 13 3-9 2 3h6' fill='none' stroke='%235eead4' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
:root{
--bg:#0b0f14; --panel:#101720; --line:#1d2836; --text:#e6edf3;
--mut:#8b98a5; --accent:#5eead4; --accent-ink:#06201b;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--bg);color:var(--text);
font:16px/1.65 "IBM Plex Sans",system-ui,sans-serif;}
code,.mono{font-family:"IBM Plex Mono",monospace}
.container{max-width:880px;margin:0 auto;padding:0 24px}
a{color:var(--accent)}
header.site{border-bottom:1px solid var(--line);padding:18px 0}
header.site .container{display:flex;align-items:center;justify-content:space-between}
.brand{display:flex;gap:10px;align-items:center;color:var(--text);text-decoration:none;font-weight:600}
.brand svg{stroke:var(--accent)}
.btn{display:inline-block;padding:12px 22px;border-radius:8px;text-decoration:none;font-weight:600;border:1px solid transparent}
.btn-primary{background:var(--accent);color:var(--accent-ink)}
.btn-ghost{border-color:var(--line);color:var(--text)}
section{padding:56px 0}
.hero{padding:88px 0 64px}
.eyebrow{color:var(--mut);font-family:"IBM Plex Mono",monospace;font-size:13px;letter-spacing:.08em;text-transform:uppercase;margin:0 0 14px}
h1{font-size:clamp(30px,5vw,46px);line-height:1.15;margin:0 0 18px;font-weight:600}
.lead{color:var(--mut);font-size:19px;max-width:640px;margin:0 0 30px}
.hero-cta{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
.price-tag{font-family:"IBM Plex Mono",monospace;color:var(--accent)}
h2{font-size:26px;font-weight:600;margin:0 0 22px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:22px}
.card h3{margin:0 0 8px;font-size:17px;font-weight:600}
.card p{margin:0;color:var(--mut);font-size:15px}
ul.check{list-style:none;padding:0;margin:0}
ul.check li{padding:9px 0 9px 30px;position:relative;border-bottom:1px dashed var(--line)}
ul.check li:last-child{border-bottom:none}
ul.check li::before{content:"✓";position:absolute;left:4px;color:var(--accent);font-family:"IBM Plex Mono",monospace}
ul.check li span{color:var(--mut)}
.cta-band{background:var(--panel);border-block:1px solid var(--line);text-align:center}
.cta-band .lead{margin-inline:auto}
footer{border-top:1px solid var(--line);color:var(--mut);font-size:14px;padding:28px 0}
.note{color:var(--mut);font-size:14px}
</style>
</head>
<body>
<header class="site">
<div class="container">
<a class="brand" href="#top">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l2-5 4 10 3-7 2 2h5"/></svg>
42 · AI Agent Studio
</a>
<a class="btn btn-primary" href="https://cil.by/">Записаться на сессию</a>
</div>
</header>
<main>
<section class="hero" id="top">
<div class="container">
<p class="eyebrow">Technology cooperative · cil.by</p>
<h1>AI Agent Session — 60 минут, за которые появится результат</h1>
<p class="lead">Живая рабочая сессия с AI-агентом студии кооператива 42. Не демо и не консультация «в порядке беседы»: агент работает с вашими реальными данными и инструментами прямо в звонке.</p>
<div class="hero-cta">
<a class="btn btn-primary" href="https://cil.by/">Забронировать сессию →</a>
<span class="price-tag mono">$150 · one-time · 60 мин</span>
</div>
</div>
</section>
<section id="includes">
<div class="container">
<h2>Что входит в сессию</h2>
<div class="grid">
<div class="card">
<h3>Разбор задачи</h3>
<p>Формулируем вашу задачу так, чтобы её можно было автоматизировать: вход, выход, критерий готовности.</p>
</div>
<div class="card">
<h3>Работа агента вживую</h3>
<p>Агент со shell, файлами и поиском по базе знаний делает реальный кусок работы на ваших данных.</p>
</div>
<div class="card">
<h3>Прототип за звонок</h3>
<p>Скрипт, страница, отчёт или интеграция — минимальный работающий артефакт к концу часа.</p>
</div>
<div class="card">
<h3>Запись и саммари</h3>
<p>Звонок записывается, транскрипт и итоги приходят вам на почту автоматически.</p>
</div>
</div>
</div>
</section>
<section id="for-whom">
<div class="container">
<h2>Для кого</h2>
<ul class="check">
<li>Основателям и продакт-менеджерам — <span>быстро проверить, отдать ли задачу агенту, и что это даст в цифрах</span></li>
<li>Операционным командам — <span>рутинные ручные процессы: отчёты, перенос данных, мониторинг, воронки</span></li>
<li>Разработчикам — <span>поднять ритмичного агента (тик-так, CQRS, эскалации) на своей инфраструктуре</span></li>
<li>Всем, кому нужен один конкретный артефакт — <span>лендинг, скрипт, аудит или разбор — в течение часа, а не недели</span></li>
</ul>
</div>
</section>
<section class="cta-band">
<div class="container">
<h2>$150 · 60 минут · one-time</h2>
<p class="lead">Без подписки и обязательств. Если после сессии захотите агента «на постоянку» — расскажем, как это устроено в кооперативе.</p>
<a class="btn btn-primary" href="https://cil.by/">Забронировать сессию →</a>
<p class="note">Бронь через портал <a href="https://cil.by/">cil.by</a>: выберите слот, подтверждение и ссылку на звонок придут на почту.</p>
</div>
</section>
</main>
<footer>
<div class="container">
Кооператив 42 · <a href="https://cil.by/">cil.by</a> · AI Agent Session — продукт агентной студии, вне presale-линейки.
</div>
</footer>
</body>
</html>

0
tasks.db Normal file
View file

143
tests/test_agent_channel.py Normal file
View file

@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""t-261015090129-ef5029: канал агент↔агент через MCP (ask_agent/poll_messages).
Запуск: ./venv/bin/python tests/test_agent_channel.py
Сеть НЕ трогается: harness.agent_channel._rpc_tool подменяется фейком —
"портал" отвечает заготовками ask_agent/poll_messages. Проверяет:
- pull(): agent_message → MessageReceived в стор (kind=agent_message,
chat_id=portal:agent:<from>, update_id=id события портала);
- дедуп: повторный pull с теми же id не дублирует инбокс (курсор портала
не сдвинулся — стор второй линией отсекает);
- push_agent(): ask_agent(to, text, kind) — ok:true → True; error → False;
- send_reply() на agent-сообщение: ветка portal:agent:* уходит ask_agent'ом
(fake перехватывает), ReplySent пишется, сообщение закрывается;
- send_batch_reply(): сводный ответ агенту одним ask_agent;
- sec_reflex(): rate-limit (не чаще MIN_REFLEX_GAP), после сброса — pull.
"""
import json
import os
import sys
_DST = "/tmp/simbiot-test-agent-channel.db"
for p in (_DST, _DST + "-wal", _DST + "-shm"):
if os.path.exists(p):
os.unlink(p)
os.environ["SIMBIOT_DB"] = _DST
os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = "agt_test_agent_channel"
os.environ.pop("SIMBIOT_HOME", None)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from harness import agent_channel, es, tg_inbox
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("" if cond else ("| " + str(extra))))
if not cond:
fails.append(name)
es.init_db()
# ── фейк MCP-шлюза ──────────────────────────────────────────────────────────
CALLS = []
def _fake_rpc(tool, arguments):
CALLS.append((tool, arguments))
if tool == "poll_messages":
return json.dumps({"messages": FAKE_QUEUE.get("messages", []),
"count": len(FAKE_QUEUE.get("messages", [])),
"note": "ok"})
if tool == "ask_agent":
return json.dumps({"ok": True, "to": arguments.get("to"),
"delivered": "agent_message"})
return json.dumps({"error": "unknown"})
agent_channel._rpc_tool = _fake_rpc
FAKE_QUEUE = {"messages": [
{"id": 9001, "from": "OpenCode", "kind": "help",
"text": "🆘 Запрос помощи от «OpenCode»:\n\nСимбиот, глянь deploy_app для groupsign",
"ts": "2026-10-15 09:00:00"},
{"id": 9002, "from": "Roy", "kind": "handoff",
"text": "🤝 Передача задачи от «Roy»:\n\nЗабери мою задачу по туннелям",
"ts": "2026-10-15 09:05:00"},
]}
# ── 1. pull → инбокс ────────────────────────────────────────────────────────
n = agent_channel.pull()
check("pull инжестит 2 сообщения", n == 2, n)
model = es.run_projections()
inbox = model.get("inbox") or {}
m1 = inbox.get("m-9001")
check("m-9001 в сторе", m1 is not None)
if m1:
check("chat_id агентный", m1.get("chat_id") == "portal:agent:OpenCode", m1.get("chat_id"))
check("kind=agent_message", m1.get("kind") == "agent_message")
check("username=агент", m1.get("username") == "OpenCode")
check("agent_kind=help", m1.get("agent_kind") == "help")
check("m-9002 в сторе", inbox.get("m-9002") is not None)
# ── 2. дедуп: тот же poll снова ─────────────────────────────────────────────
n2 = agent_channel.pull()
check("повторный pull — 0 новых (дедуп стора)", n2 == 0, n2)
# ── 3. push_agent ───────────────────────────────────────────────────────────
ok = agent_channel.push_agent("OpenCode", "deploy ок, лог чист", "help")
check("push_agent ok", ok is True and CALLS[-1][0] == "ask_agent", CALLS[-1:])
args = CALLS[-1][1]
check("ask_agent аргументы", args.get("to") == "OpenCode"
and args.get("text") == "deploy ок, лог чист"
and args.get("kind") == "help", args)
ok = agent_channel.push_agent("", "пустой адресат")
check("push_agent без адресата — False", ok is False)
ok = agent_channel.push_agent("Nobody", "")
check("push_agent без текста — False", ok is False)
# ── 4. send_reply на агентское сообщение ────────────────────────────────────
rid = tg_inbox.send_reply("m-9001", "Симбиот: deploy посмотрел, всё чисто. Детали в тике.")
check("send_reply вернул id", rid == "m-9001", rid)
check("ушёл ask_agent (не TG)", CALLS[-1][0] == "ask_agent", CALLS[-1:])
check("адресат из chat_id", CALLS[-1][1].get("to") == "OpenCode", CALLS[-1][1])
model = es.run_projections()
m1 = (model.get("inbox") or {}).get("m-9001")
check("m-9001 закрыт (replied)", bool(m1 and m1.get("replied")))
check("канал ответа agent_mcp", m1 and m1.get("reply_text") == "Симбиот: deploy посмотрел, всё чисто. Детали в тике.")
# ── 5. батч: сводный ответ агенту ───────────────────────────────────────────
FAKE_QUEUE["messages"] = [{"id": 9003, "from": "Roy", "kind": "review",
"text": "Проверь мой отчёт по туннелям", "ts": "2026-10-15 09:10:00"},
{"id": 9004, "from": "Roy", "kind": "review",
"text": "И заодно по слотам", "ts": "2026-10-15 09:11:00"}]
n = agent_channel.pull()
check("pull инжестит вторую партию 2 сообщения", n == 2, n)
CALLS.clear()
head = tg_inbox.send_batch_reply(["m-9003", "m-9004"], "Принял оба: отчёт и слоты проверю.")
check("батч закрыт головным", head == "m-9003", head)
ask_calls = [c for c in CALLS if c[0] == "ask_agent"]
check("один ask_agent на батч", len(ask_calls) == 1, CALLS)
model = es.run_projections()
m3 = (model.get("inbox") or {}).get("m-9003")
m4 = (model.get("inbox") or {}).get("m-9004")
check("оба закрыты", bool(m3 and m3.get("replied") and m4 and m4.get("replied")))
# ── 6. sec_reflex rate-limit ────────────────────────────────────────────────
agent_channel._last_reflex["ts"] = 0.0
r1 = agent_channel.sec_reflex()
r2 = agent_channel.sec_reflex()
check("первый рефлекс работает", "ingested" in r1, r1)
check("второй — rate-limit", r2.get("skipped") == "rate-limit", r2)
# ── 7. pull без токена — тихо ───────────────────────────────────────────────
_tok = os.environ.pop("SIMBIOT_PORTAL_MCP_TOKEN")
n = agent_channel.pull()
check("без токена — 0, без исключений", n == 0, n)
os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = _tok
print()
print("FAILS:", len(fails), fails if fails else "")
sys.exit(1 if fails else 0)

View file

@ -67,6 +67,35 @@ check("build_context: ГОТОВАЯ команда со ВСЕМИ id",
f"cli.py tg-reply {ids_line} -c" in sec, sec[:400]) f"cli.py tg-reply {ids_line} -c" in sec, sec[:400])
check("build_context: инструкция «за ЭТОТ тик»", "именно в ЭТОМ тике" in sec) check("build_context: инструкция «за ЭТОТ тик»", "именно в ЭТОМ тике" in sec)
# ── 1b. t-261015222515: память разговора — отвеченные реплики тех же чатов ──
# Сценарий эскалации: обсуждали SPA-договор, мин-тик ответил (ReplySent) —
# на следующем тике контекст ДОЛЖЕН помнить нить (текст + наш ответ),
# иначе триаж отвечает без памяти о разговоре (Alex: «только что же обсуждали»).
fake["inbox"]["m-70100"] = {
"update_id": 70100, "chat_id": 42, "text": "вот этот SPA-договор мы только что обсуждали",
"kind": "text", "replied": True, "ignored": False,
"reply_text": "да, это вторая версия без ЗП-обязательств",
"ts": "2026-09-30T06:55:00+00:00",
}
fake["inbox"]["m-70101"] = {
"update_id": 70101, "chat_id": 7, "text": "сообщение ДРУГОГО чата — не должно попасть",
"kind": "text", "replied": True, "ignored": False,
"ts": "2026-09-30T06:56:00+00:00",
}
ctx = build_context(LEVELS["min"], fake)
hist = re.search(r"НЕДАВНИЙ РАЗГОВОР.*?(?:\n\n|$)", ctx, re.S)
hsec = hist.group(0) if hist else ""
check("память: секция НЕДАВНИЙ РАЗГОВОР есть", bool(hist), ctx[:200])
check("память: отвеченное сообщение в контексте", "SPA-договор" in hsec, hsec[:200])
check("память: наш ответ виден", "без ЗП-обязательств" in hsec, hsec[:300])
check("память: чужой чат НЕ в памяти", "ДРУГОГО чата" not in hsec and "m-70101" not in hsec)
check("память: помечена «НЕ отвечай снова»", "НЕ отвечай" in hsec)
# без неотвеченных (нет батча) — секции нет: нечего контекстуализировать
only_replied = {"inbox": {"m-70100": dict(fake["inbox"]["m-70100"])},
"tasks": {}, "escalations": {}, "policy": {}}
check("память: без неотвеченных секции нет",
"НЕДАВНИЙ РАЗГОВОР" not in build_context(LEVELS["min"], only_replied))
# ── 2. send_batch_reply: одна отправка → ReplySent на каждое входящее ── # ── 2. send_batch_reply: одна отправка → ReplySent на каждое входящее ──
# сценарий из постановки: 7 сообщений одного разговора об экономии тиков # сценарий из постановки: 7 сообщений одного разговора об экономии тиков
for i, text in enumerate([ for i, text in enumerate([

View file

@ -134,6 +134,12 @@ class _FakeES:
def append_event(self, aggregate, aid, evtype, data={}): def append_event(self, aggregate, aid, evtype, data={}):
written.append((evtype, aid)) written.append((evtype, aid))
return "x" return "x"
# t-261015000631-13aef0: условный клейм в стабе всегда успешен
def claim_task_if_ready(self, task_id, claim):
written.append(("TaskClaimed", task_id))
return ("x", 1)
class _FakeRunner: class _FakeRunner:
@staticmethod @staticmethod
def run_llm_tick(level_cfg, prompt, session_id=None, trace_prefix=None, timeout=None): def run_llm_tick(level_cfg, prompt, session_id=None, trace_prefix=None, timeout=None):

View file

@ -162,6 +162,12 @@ class _FakeES:
written.append((evtype, aid)) written.append((evtype, aid))
return "x" return "x"
# t-261015000631-13aef0: клейм теперь условный и атомарный (es.claim_task_if_ready);
# стаб без __getattr__-заглушки: всегда успешен, как готовая задача
def claim_task_if_ready(self, task_id, claim):
written.append(("TaskClaimed", task_id))
return ("x", 1)
class _FakeRunner: class _FakeRunner:
@staticmethod @staticmethod

View file

@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""t-261015000631-13aef0: атомарный клейм — регресс гонки двойного claim.
Инцидент 2026-10-15 00:00: L-задача t-261014221908-d20347 клеймнута ДВАЖДЫ
(TaskClaimed id=2767823 hour 00:00:00 и id=2767828 day 00:00:01) — оба уровня
читали read model до клейма друг друга, а старый цикл писал TaskClaimed по
снапшоту без перепроверки → двойное исполнение = двойной burn 9router.
Проверяет:
1) es.claim_task_if_ready: ready → успех; повторно/не-ready/несуществующая
→ False; после requeue (TaskPromoted) → снова успех (обратимость);
2) атомарность СТОРА: 8 потоков параллельно клеймят одну ready-задачу —
ровно один truthy-результат, ровно одно событие TaskClaimed;
3) интеграция — точный сценарий прод-инцидента: hour- и day-тик с ОДНИМ
и тем же устаревшим снапшотом модели (оба видят задачу ready) →
ровно один TaskClaimed и один TaskCompleted, тик-проигравший
отрабатывает вхолостую без падения;
4) свёртка статуса в сторе согласована с проекциями (done не ре-клеймится).
Запуск: ./venv/bin/python tests/test_task_claim_race.py
"""
import os
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
_DB = "/tmp/simbiot-test-claim-race.db"
for _p in (_DB, _DB + "-wal", _DB + "-shm"):
if os.path.exists(_p):
os.unlink(_p)
os.environ["SIMBIOT_DB"] = _DB
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, ("" if cond else " | " + str(extra)))
if not cond:
fails.append(name)
def events_of(tid, etype=None):
db = es._conn()
try:
if etype:
rows = db.execute(
"SELECT * FROM events WHERE aggregate='task' AND aggregate_id=? "
"AND event_type=?", (tid, etype)).fetchall()
else:
rows = db.execute(
"SELECT * FROM events WHERE aggregate='task' AND aggregate_id=? "
"ORDER BY version", (tid,)).fetchall()
return [dict(r) for r in rows]
finally:
db.close()
def status_of(tid):
t = (es.run_projections().get("tasks") or {}).get(tid)
return (t or {}).get("status")
# ── 1. Семантика claim_task_if_ready ──────────────────────────────────────
tid, _ = commands.submit_task(title="гонка клейма: атомарный claim",
minutes=60, details="фикс t-261015000631")
check("1.1: ready → клейм успешен", bool(es.claim_task_if_ready(
tid, {"level": "hour", "session_id": "s-hour-1"})))
check("1.2: running → клейм ОТКАЗАН (второй уровень не клеймнет)",
es.claim_task_if_ready(tid, {"level": "day", "session_id": "s-day-1"}) is False)
check("1.3: событий TaskClaimed ровно одно",
len(events_of(tid, "TaskClaimed")) == 1, str(len(events_of(tid, "TaskClaimed"))))
check("1.4: несуществующая задача → False",
es.claim_task_if_ready("t-nope-000000", {}) is False)
# закрыли → клейм невозможен; requeue вернул в очередь → снова возможен
es.append_event("task", tid, "TaskCompleted", {"answer": "done"})
check("1.5: done → клейм ОТКАЗАН",
es.claim_task_if_ready(tid, {"level": "hour", "session_id": "s-hour-2"}) is False)
commands.requeue_task(tid, reason="тест обратимости")
check("1.6: после requeue (TaskPromoted → ready) клейм снова успешен",
bool(es.claim_task_if_ready(tid, {"level": "min", "session_id": "s-min-1"})))
check("1.7: свёртка стора == проекции (running)",
status_of(tid) == "running", status_of(tid))
# ── 2. Атомарность стора: 8 потоков, одна ready-задача ────────────────────
tid2, _ = commands.submit_task(title="гонка клейма: 8 потоков",
minutes=60, details="один победитель")
barrier = threading.Barrier(8)
def _claim(_i):
barrier.wait() # максимальная конкуренция на одной ready-задаче
return es.claim_task_if_ready(tid2, {"level": "race", "session_id": f"s-{_i}"})
with ThreadPoolExecutor(max_workers=8) as ex:
results = list(ex.map(_claim, range(8)))
winners = [r for r in results if r]
losers = [r for r in results if r is False]
check("2.1: ровно ОДИН победитель из 8 параллельных", len(winners) == 1, str(results))
check("2.2: остальные 7 — чистый False (не None-дубль)",
len(losers) == 7, str(results))
check("2.3: TaskClaimed в сторе ровно один",
len(events_of(tid2, "TaskClaimed")) == 1,
str(len(events_of(tid2, "TaskClaimed"))))
check("2.4: проекция видит running", status_of(tid2) == "running", status_of(tid2))
# ── 3. Интеграция: прод-сценарий hour+day со stale-снапшотом ───────────────
from harness import scheduler
from harness.levels import LEVELS
import harness.isolated_runner as _ir
tid3, _ = commands.submit_task(title="гонка клейма: hour vs day",
minutes=60, details="сценарий 2026-10-15 00:00")
# ОДИН снапшот модели для обоих тиков — как в проде: оба уровня читали модель
# ДО клейма друг друга. Снапшот устаревает сразу после первого клейма —
# именно это раньше и рождало второй TaskClaimed.
stale_model = es.run_projections()
check("3.1: подготовка — в снапшоте задача ready",
(stale_model["tasks"].get(tid3) or {}).get("status") == "ready")
_orig_run_llm_tick = _ir.run_llm_tick
def _fake_run_llm_tick(level_cfg, prompt, session_id=None, trace_prefix=None, timeout=None):
return (f"дайджест {level_cfg.name}: отработана пачка",
{"model_calls": 1}, f"trace-{level_cfg.name}")
ticks_out = {}
tick_err = {}
def _run_tick(level_name, seq):
try:
# fresh-снапшот имитировать НЕ будем: оба тика получают ОДИН stale —
# худший случай. Внутри тика claim пойдёт в стор атомарно.
ticks_out[level_name] = scheduler.run_level_tick(
LEVELS[level_name], seq, stale_model)
except Exception as e: # noqa: BLE001
tick_err[level_name] = repr(e)
try:
_ir.run_llm_tick = staticmethod(_fake_run_llm_tick)
barrier2 = threading.Barrier(2)
def _run(level_name, seq):
barrier2.wait()
_run_tick(level_name, seq)
th_hour = threading.Thread(target=_run, args=("hour", 4242))
th_day = threading.Thread(target=_run, args=("day", 4243))
th_hour.start(), th_day.start()
th_hour.join(), th_day.join()
finally:
_ir.run_llm_tick = _orig_run_llm_tick
check("3.2: оба тика завершились без исключений", not tick_err, str(tick_err))
claims3 = events_of(tid3, "TaskClaimed")
comps3 = events_of(tid3, "TaskCompleted")
check("3.3: TaskClaimed ровно ОДИН (регресс: было два)",
len(claims3) == 1, f"n={len(claims3)}")
check("3.4: TaskCompleted ровно ОДИН (исполнение не задвоилось)",
len(comps3) == 1, f"n={len(comps3)}")
winner_level = (claims3[0]["data"] if claims3 else "").find('"level"') >= 0
check("3.5: клейм содержит level победителя", bool(winner_level),
claims3[0]["data"][:120] if claims3 else "нет событий")
loser = [lv for lv in ("hour", "day")
if lv != __import__("json").loads(claims3[0]["data"])["level"]][0] \
if claims3 else "?"
check("3.6: проигравший тик отработал вхолостую (пустой батч, без падения)",
loser in ticks_out and ticks_out[loser][1] in ([], None),
str({k: v[1] for k, v in ticks_out.items()}))
check("3.7: финальный статус задачи done", status_of(tid3) == "done", status_of(tid3))
print()
print("FAILS:", len(fails))
sys.exit(1 if fails else 0)

103
tests/test_ttft_retry.py Normal file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env python
"""E2E-проверка TTFT-watchdog обёртки воркера (e-261014163808-4da3ec).
Три сценария на фейковом стриме:
1. живой апстрим (контент сразу) → все чанки, ни одного ретрая;
2. висит до первого токена, оживёт на 2-й попытке → ретрай, ответ完整;
3. висит всегда (3 попытки) → TimeoutError с внятным текстом.
Плюс дым: сборка модели payload-ом возвращает прокси с рабочим stream().
Запуск: ./venv/bin/python tests/test_ttft_retry.py
"""
import asyncio
import os
import sys
import types
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from harness import _llm_worker as W # noqa: E402
FAILS = []
def check(name, cond, extra=""):
print(("PASS " if cond else "FAIL ") + name + (f" | {extra}" if extra and not cond else ""))
if not cond:
FAILS.append(name)
def chunk(ct):
return {"chunk_type": ct}
def fake_stream_model(script):
"""script: список «попыток», каждая — список чанков или raise Hang."""
calls = {"n": 0}
class M:
async def stream(self, *a, **k):
i = calls["n"]
calls["n"] += 1
act = script[i]
if act == "HANG":
await asyncio.sleep(30) # дольше watchdog-бюджета
yield chunk("message_start")
return
for c in act:
yield chunk(c)
return M(), calls
async def run(model):
wrapped = W._with_ttft_retry(model, "hour", 90)
out = []
async for c in wrapped.stream():
out.append(W._chunk_type(c))
return out
async def main():
os.environ["SIMBIOT_LLM_TTFT_BUDGET"] = "0.5" # быстрый watchdog для теста
W._TTFT_FIRST_TOKEN_BUDGET = 0.5
# 1. живой апстрим
m, calls = fake_stream_model([
["message_start", "content_delta", "content_delta", "message_stop"]])
out = await run(m)
check("живой стрим проходит целиком",
out == ["message_start", "content_delta", "content_delta", "message_stop"],
str(out))
check("живой стрим — 1 попытка", calls["n"] == 1, str(calls))
# 2. виснет, оживает на 2-й
m, calls = fake_stream_model([
"HANG",
["message_start", "content_delta", "message_stop"]])
out = await run(m)
check("зависший вызов ретраится и отвечает",
out == ["message_start", "content_delta", "message_stop"], str(out))
check("ретрай сделал ровно 2 попытки", calls["n"] == 2, str(calls))
# 3. виснет всегда
m, calls = fake_stream_model(["HANG", "HANG", "HANG"])
try:
await run(m)
check("глухой апстрим даёт TimeoutError", False, "не упал")
except TimeoutError as e:
check("глухой апстрим даёт TimeoutError", "завис до первого токена" in str(e), str(e))
check("глухой апстрим — ровно 3 попытки", calls["n"] == 3, str(calls))
# 4. делегирование атрибутов прокси
m, _ = fake_stream_model([["content_delta"]])
m.foo = "bar"
wrapped = W._with_ttft_retry(m, "hour", 90)
check("прокси делегирует атрибуты", getattr(wrapped, "foo", None) == "bar")
print()
print("ИТОГ:", "ВСЕ ОК" if not FAILS else f"ПРОВАЛЫ: {FAILS}")
sys.exit(1 if FAILS else 0)
asyncio.run(main())