Жизненный цикл сессии
Аудитория: разработчики и мейнтейнеры шлюза Исходные файлы:
gateway/session.py(~1200 строк + соседниеsession_*.py),gateway/run.py(~5500 строк фасада + фазы вrun_*.py),gateway/config.pyПоследнее обновление: 2026-06-16
Обзор
Сессия — это непрерывный разговор между агентом и одним или несколькими пользователями на мессенджер-платформе. Жизненный цикл сессии определяет, когда разговор сохраняется, когда сбрасывается, как переживает перезапуски шлюза и как сообщения ставятся в очередь при параллельных операциях.
Система сессий живёт в основном в двух модулях:
gateway/session.py— модель данных (SessionSource,SessionEntry,SessionContext), генерация ключей (build_session_key) и основное хранилище (SessionStore).gateway/run.py— фасад раннера шлюза (GatewayRunner), который встраивает сессии в конвейер обработки сообщений; сами фазы лежат в соседнихrun_*.py: обслуживание сессий (run_watchers.py), кэш агентов (run_agent_cache.py), восстановление после перезапуска (session_recovery.py) и очередь сообщений (run_busy.py).
1. SessionSource — дескриптор источника сообщения
SessionSource — замороженная запись о том, откуда пришло сообщение. Она прикрепляется к
каждому входящему MessageEvent и используется для маршрутизации, изоляции и внедрения контекста.
Поля
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
platform | Platform | (обязательное) | Enum мессенджер-платформы (telegram, discord, slack, signal, whatsapp, matrix, local и т. д.). |
chat_id | str | (обязательное) | Идентификатор чата/группы/канала на уровне платформы. Проходит через преобразование chat_id_key адаптера. |
chat_name | Optional[str] | None | Человекочитаемое имя чата или группы. |
chat_type | str | "dm" | Одно из "dm", "group", "channel", "thread". Управляет генерацией ключа сессии и изоляцией. |
user_id | Optional[str] | None | Идентификатор пользователя на платформе. Используется для авторизации и изоляции сессий по пользователям. |
user_name | Optional[str] | None | Отображаемое имя автора сообщения. Внедряется в системный промпт. |
thread_id | Optional[str] | None | Идентификатор темы форума / треда Discord / треда Slack. Различает разговоры в тредах. |
chat_topic | Optional[str] | None | Тема или описание канала (topic канала Discord, purpose канала Slack). |
user_id_alt | Optional[str] | None | Стабильный альтернативный ID, специфичный для платформы (UUID в Signal, union_id в Feishu). Используется, когда user_id эфемерный. |
chat_id_alt | Optional[str] | None | Внутренний ID группы Signal — сопоставляет идентификатор группы Signal V2 с его канонической формой. |
is_bot | bool | False | True, если автор сообщения — бот или вебхук (боты Discord). |
guild_id | Optional[str] | None | Идентификатор области: гильдия Discord / рабочее пространство Slack / сервер Matrix. |
parent_chat_id | Optional[str] | None | Родительский канал, когда chat_id указывает на тред. |
message_id | Optional[str] | None | ID сообщения-триггера. Нужен для операций pin/reply/react и внедрения ID в Discord (внедрённая пометка [Triggering message id: …] едет только в сообщении, уходящем в API; сохранённая строка пользователя хранит авторский текст). |
role_authorized | bool | False | True, если адаптер выдал доступ по роли на платформе (а не по ID конкретного пользователя). |
Основные методы
description(свойство:str) — человекочитаемая сводка, например"DM with Alice","group: My Group, thread: 12345".to_dict()/from_dict()— сериализация туда и обратно для хранения вsessions.json.
2. SessionEntry — запись активной сессии
SessionEntry — запись метаданных отдельной сессии; хранится в памяти и сбрасывается в
{sessions_dir}/sessions.json. Каждая запись сопоставляет session_key с текущим session_id.
Поля
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
session_key | str | (обязательное) | Детерминированный ключ, идентифицирующий полосу разговора (см. §4). |
session_id | str | (обязательное) | Уникальный идентификатор конкретного воплощения разговора. Формат: YYYYMMDD_HHMMSS_<8hex>. |
created_at | datetime | (обязательное) | Когда создано это воплощение сессии. |
updated_at | datetime | (обязательное) | Метка последней активности; по ней ведётся обслуживание ресурсов. |
origin | Optional[SessionSource] | None | Источник, создавший сессию; используется для маршрутизации доставки. |
display_name | Optional[str] | None | Отображаемое имя чата (берётся из SessionSource.chat_name). |
platform | Optional[Platform] | None | Enum платформы, сохраняемый для маршрутизации после перезапусков. |
chat_type | str | "dm" | Тип чата; тоже сохраняется — для поиска политики. |
input_tokens | int | 0 | Накопленное число входных (промпт) токенов LLM. |
output_tokens | int | 0 | Накопленное число выходных (completion) токенов LLM. |
cache_read_tokens | int | 0 | Накопленное число токенов, прочитанных из кэша промптов. |
cache_write_tokens | int | 0 | Накопленное число токенов, записанных в кэш промптов. |
total_tokens | int | 0 | Общее число токенов за все ходы. |
estimated_cost_usd | float | 0.0 | Оценка накопленной стоимости в USD. |
cost_status | str | "unknown" | Метка статуса учёта стоимости. |
last_prompt_tokens | int | 0 | Последнее число токенов промпта, сообщённое API. Нужно для точной предварительной проверки перед сжатием. |
Булевы флаги (конечный автомат)
У SessionEntry есть несколько булевых флагов, которые образуют простой конечный автомат:
он определяет поведение сессии при следующем обращении.
| Флаг | Тип | По умолчанию | Описание |
|---|---|---|---|
was_auto_reset | bool | False | Ставится, когда явная приостановка приводит к замене сессии. Также сохраняется в исторических записях. |
auto_reset_reason | Optional[str] | None | "suspended" при явной приостановке; в старых записях могут остаться исторические причины сброса. |
reset_had_activity | bool | False | Была ли активность в заменённой сессии. |
is_fresh_reset | bool | False | Ставится явными /new или /reset. При первом сообщении запускает повторное внедрение навыков темы/канала. Отделён от was_auto_reset, чтобы не показывать вводящие в заблуждение уведомления «сессия истекла». |
expiry_finalized | bool | False | Историческая ограда финализации, сохранённая для восстановления; таймеры её больше не пишут. |
suspended | bool | False | Сигнал жёсткой принудительной очистки. Ставится по /stop или при эскалации зациклившегося перезапуска (3+ последовательных неудачных перезапуска). При следующем get_or_create_session() принудительно создаёт новый session_id независимо от resume_pending. |
resume_pending | bool | False | Маркер мягкого восстановления. Ставится recover_interrupted_turns() (восстановление после сбоя для помеченного хода без ответа) или таймаутом drain. При следующем обращении сохраняет существующий session_id — пользователь продолжает на том же транскрипте. Снимается после завершения следующего успешного хода. |
resume_reason | Optional[str] | None | Почему поставлен маркер возобновления: "restart_timeout", "shutdown_timeout", "restart_interrupted". |
last_resume_marked_at | Optional[datetime] | None | Метка времени последней постановки resume_pending. |
Логика переходов состояний (get_or_create_session)
┌──────────┐
│ Incoming │
│ Message │
└────┬─────┘
│
▼
┌──────────────────────┐
│ session_key exists │──── No ──► Create fresh SessionEntry
│ AND !force_new │
└──────────┬───────────┘
│ Yes
▼
┌──────────────────────┐
│ entry.suspended? │──── Yes ──► Auto-reset: new session_id
└──────────┬───────────┘ (reason="suspended")
│ No
▼
┌──────────────────────┐
│ entry.resume_pending?│──── Yes ──► Return existing entry
└──────────┬───────────┘ (preserve session_id)
│ No Clear flag on next successful turn
▼
┌──────────────────────┐
│ Policy says reset? │──── Yes ──► Auto-reset: new session_id
└──────────┬───────────┘ (reason="idle"/"daily")
│ No
▼
┌──────────────────────┐
│ Return existing │
│ entry, bump │
│ updated_at │
└──────────────────────┘
Порядок приоритетов в get_or_create_session():
suspended=True→ всегда принудительный сброс (жёсткая очистка)resume_pending=True→ сохранить session_id (мягкое восстановление)- Триггеров нет → вернуть существующую запись (обновить
updated_at)
3. SessionStore — хранилище и операции
SessionStore — основной слой хранения. Он держит словарь в памяти (_entries), сбрасываемый
в sessions.json, а каноническим хранилищем метаданных сессий и транскриптов сообщений служит
SQLite (SessionDB).
Конструктор
SessionStore(sessions_dir: Path, config: GatewayConfig, has_active_processes_fn=None)
sessions_dir— каталог, где лежитsessions.json.config— экземплярGatewayConfigс настройками маршрутизации и обслуживания.has_active_processes_fn— необязательный колбэк поsession_key, проверяющий, есть ли запущенные фоновые процессы. Сессии с активными процессами защищены от вычистки записей маршрутизации.
Операции (методы)
| Метод | Описание |
|---|---|
get_or_create_session(source, force_new=False) | Основная точка входа. Возвращает существующий SessionEntry или создаёт новый. Учитывает явную приостановку и состояние восстановления после перезапуска. Создаёт/завершает записи в SQLite. |
update_session(session_key, last_prompt_tokens=None) | Лёгкое обновление метаданных после взаимодействия. Обновляет updated_at, при необходимости записывает last_prompt_tokens. |
reset_session(session_key, display_name=None) | Явный сброс (по /new или /reset). Создаёт новый session_id, ставит is_fresh_reset=True. Завершает старую сессию в SQLite и создаёт новую. |
switch_session(session_key, target_session_id, *, expected_session_id=None) | Переключение на другой существующий ID сессии (по /resume). Завершает текущую сессию в SQLite, заново открывает целевую. С expected_session_id= перенацеливание становится операцией compare-and-swap: возвращает None и ничего не переключает, если ключ уже не указывает на эту сессию, — так вызывающий код, который разрешал по снимку через await (повторное закрепление при асинхронном делегировании, починка привязки темы Telegram), не перезапишет параллельный /new или /resume. |
suspend_session(session_key) | Помечает сессию как suspended=True (по /stop). Принудительный автосброс при следующем обращении. |
mark_resume_pending(session_key, reason) | Помечает сессию как resume_pending=True (по таймауту drain). При следующем обращении session_id сохраняется. НЕ перекрывает suspended=True. |
clear_resume_pending(session_key) | Снимает resume_pending после успешного возобновлённого хода. Вызывается из шлюза после возврата run_conversation(). |
recover_interrupted_turns(max_age_seconds) | Восстановление после сбоя: переводит устойчивые маркеры активного хода, оставленные умершим процессом, в resume_pending=True (restart_interrupted). Сессии без маркера свой ход завершили, и их не трогают. Вызывается при старте после некорректного завершения. |
prune_old_entries(max_age_days) | Удаляет записи старше max_age_days (по updated_at). Пропускает записи suspended и сессии с активными процессами. |
list_sessions(active_minutes=None) | Возвращает все сессии, при желании отфильтрованные по недавней активности. Сортировка по updated_at по убыванию. |
lookup_by_session_id(session_id) | Находит активный SessionEntry по сохранённому ID сессии. |
has_any_sessions() | Проверяет, создавалась ли хоть одна сессия когда-либо (смотрит историю в SQLite, а не только словарь в памяти). |
append_to_transcript(session_id, message, skip_db=False) | Дописывает сообщение в транскрипт SQLite. skip_db=True предотвращает дублирующую запись, если агент уже сохранил сообщение. |
rewrite_transcript(session_id, messages) | Полная замена транскрипта сессии (используется /retry, /undo, /compress). |
load_transcript(session_id) | Загружает все сообщения из транскрипта сессии в SQLite. |
rewind_session(session_id, n=1) | Откатывает n пользовательских ходов мягким удалением (аудиторский след сохраняется); тонкая обёртка над SessionDB.rewind_user_turn (hermes_state_rewind.py) — тем же единственным откатом, что используют /undo//retry в CLI и TUI. Возвращает {rewound_count, turns_undone, target_text}. |
Внутренние хелперы
_ensure_loaded()/_ensure_loaded_locked()— загружаютsessions.jsonв словарь_entries._save()— атомарная запись вsessions.jsonчерез временный файл +atomic_replace._generate_session_key(source)— делегирует вbuild_session_key()с параметрами из конфига.
Структура хранилища
{sessions_dir}/
sessions.json # Словарь _entries из памяти, сохранённый как JSON
Сопоставляет session_key → SessionEntry (только метаданные)
{session_id}.jsonl # (Устаревшее, удалено в спецификации 002)
Каноническое хранилище транскриптов — SQLite через SessionDB (из hermes_state). Файл
sessions.json хранит сопоставление session_key → session_id и метаданные записей
(флаги, метки времени, счётчики токенов). Если SQLite недоступен, хранилище откатывается
на JSONL, но это деградированный режим.
4. Правила генерации SessionKey
Ключи сессий — детерминированные строки, идентифицирующие полосу разговора. Их генерирует
build_session_key(source, group_sessions_per_user, thread_sessions_per_user).
Формат ключа
agent:main:{platform}:{chat_type}[:{chat_id}][:{thread_id}][:{participant_id}]
Правила для личных сообщений (DM)
| Сценарий | Ключ |
|---|---|
| DM с chat_id | agent:main:telegram:dm:12345 |
| DM с chat_id + тред | agent:main:telegram:dm:12345:thread_678 |
| DM без chat_id, с participant_id | agent:main:signal:dm:user_abc |
| DM без chat_id и participant_id | agent:main:telegram:dm |
| DM в WhatsApp (канонизированный) | agent:main:whatsapp:dm:{canonical_number} |
- DM всегда включают
chat_id, когда он есть, — так каждый приватный разговор изолирован. thread_idдополнительно разделяет тредовые DM внутри одного DM-чата.- Без
chat_idв качестве participant_id берётсяuser_id_altилиuser_id. - Без какого-либо идентификатора все DM на этой платформе схлопываются в одну общую сессию.
Правила для групп и каналов
| Сценарий | Ключ |
|---|---|
| Групповой чат | agent:main:telegram:group:-10012345 |
| Групповой чат, изоляция по пользователям | agent:main:telegram:group:-10012345:user_abc |
| Тред в группе, общий | agent:main:discord:group:12345:thread_678 |
| Тред в группе, по пользователям | agent:main:discord:group:12345:thread_678:user_abc |
| Канал | agent:main:slack:channel:C12345 |
| Группа WhatsApp (канонизированная) | agent:main:whatsapp:group:{canonical_id}:{participant} |
chat_idидентифицирует родительскую группу или канал.thread_idразличает треды внутри этого родителя.- Изоляцией по пользователям (добавление
participant_id) управляют:group_sessions_per_user(по умолчаниюTrue) — сессии групп и каналов изолированы.thread_sessions_per_user(по умолчаниюFalse) — треды по умолчанию общие (темы форумов Telegram, треды Discord и Slack делят одну сессию на тред).
participant_id=user_id_altилиuser_id(в таком порядке приоритета).- Идентификаторы WhatsApp канонизируются, чтобы переживать переключения алиасов JID/LID.
Особый случай: WhatsApp
Телефонные номера WhatsApp проходят через canonical_whatsapp_identifier(), которая отрезает
суффикс @s.whatsapp.net и нормализует номер в формат E.164. Это предотвращает фрагментацию
сессий, когда мост возвращает разные формы алиаса одного и того же номера.
5. Стратегия изоляции пользователей
Изоляция пользователей определяет, делят ли несколько пользователей одного чата общий разговор или каждый получает собственную приватную сессию.
Логика решения (is_shared_multi_user_session)
def is_shared_multi_user_session(source, *, group_sessions_per_user, thread_sessions_per_user):
if source.chat_type == "dm":
return False # DM всегда приватны
if source.thread_id:
return not thread_sessions_per_user # Треды: общие, если не включена изоляция по пользователям
return not group_sessions_per_user # Группы: изолированы, если не включён общий режим
Сводка
| Тип чата | По умолчанию | Настройка |
|---|---|---|
| DM | Приватный (никогда не общий) | Нет |
| Группа/канал | Изоляция по пользователям | group_sessions_per_user (по умолчанию True) |
| Тред (форум, discord) | Общий (все участники видят один контекст) | thread_sessions_per_user (по умолчанию False) |
Влияние на системный промпт
При shared_multi_user_session=True системный промпт не содержит фиксированного имени
пользователя, а вместо этого сообщает: «Multi-user {thread|session} — messages are prefixed
with [sender name]. Multiple users may participate.» Имена отправителей шлюз подставляет
префиксом к каждому пользовательскому сообщению во время выполнения — так сохраняется
кэширование промптов (системный промпт не меняется от хода к ходу).
6. Явные границы разговора
Бездействие и время по часам никогда не ротируют разговор. Явную границу создают /new и
/reset; длинные истории по-прежнему обслуживает сжатие контекста. Устаревшая конфигурация
таймеров игнорируется.
Явная приостановка по-прежнему создаёт границу при следующем входящем ходе. Восстановление уважает явные и исторические финализированные границы, а не открывает их заново. Вытеснение только по ресурсам и сбор осиротевших WebSocket-соединений оставляют разговоры возобновляемыми.
7. Восстановление после перезапуска
Система восстановления после перезапуска гарантирует, что сессии с незавершёнными ходами переживут перезапуски шлюза, сбои и таймауты drain. Это решение issue #7536.
Последовательность восстановления при старте
Gateway starts
│
▼
┌───────────────────────────────┐
│ Check for .clean_shutdown │── Exists? ──► Skip suspension (clean exit)
│ marker │
└───────────────────────────────┘
│ Missing
▼
┌───────────────────────────────┐
│ _recover_unclean_sessions() │── Marked turn with a persisted reply
│ │ → delivery ledger (sent, marked);
│ │ marked turn without one →
│ │ resume_pending (once)
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ _suspend_stuck_loop_sessions()│── Suspends sessions that have been
│ │ active across 3+ restarts
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ Queue inbound messages while │
│ startup restore runs │
│ (_startup_restore_in_progress)│
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ For each adapter, find │
│ resume_pending sessions → │
│ synthesize MessageEvent and │
│ run _handle_message to let │
│ the agent auto-continue │
└───────────────────────────────┘
Восстановление после сбоя (_recover_unclean_sessions)
Вызывается при старте шлюза, если нет маркера .clean_shutdown (сбой или неожиданный
выход). Работает только с устойчивыми маркерами активного хода и никогда — по давности
активности: чат, который просто был активен незадолго до сбоя, свой ход завершил, и
повторного ответа не получит.
Маркер ставится, когда ход начинается, и держится, пока финальный ответ не попадёт в
журнал доставки (адаптер снимает его сразу после record_delivery_obligation) или пока
не останется ничего недоставленного (потоковый ответ, подавленный или пустой ответ).
Поэтому маркер, оставшийся к старту, означает одно из двух:
- Ответ сохранён, но в журнал не попал. Сохранённый в транскрипте ответ
записывается в журнал как строка без владельца, а маркер снимается; проход при
загрузке доставляет его один раз с пометкой «Recovered reply». Ход заново не
генерируется. Ответ оценивается так же, как его оценила бы живая доставка: голый
маркер молчания (
[SILENT],NO_REPLY, …) во внутреннем ходе или ответ на диагностическое пробуждение, которое правила чата глушат, ничего не требуют (маркер снимается, ничего не отправляется и не возобновляется). Голый маркер молчания в ходе человека превращается в то же уведомление «returned only a silence marker», которое отправляет живой путь. - Ответ не сохранён.
recover_interrupted_turns()ставитresume_pending=True,resume_reason="restart_interrupted", и ход один раз автоматически возобновляется.
Время начала маркера хранится как UTC с часовым поясом и сравнивается в секундах эпохи,
поэтому перезапуск в другом локальном поясе (переход на летнее время, TZ контейнера
против юнита) не отбрасывает свежий маркер как устаревший и не принимает ответ прошлого
хода за ответ текущего. Маркер, записанный старой сборкой (наивное локальное время),
читается как локальное время хоста.
Ход, который уже есть в журнале, заново доставляет проход по журналу, и он же снимает
resume_pending для этой сессии, так что ход никогда не будет и доставлен, и отвечен
повторно.
Обнаружение зацикленных перезапусков (_suspend_stuck_loop_sessions)
Считает последовательные перезапуски через JSON-файл ({HERMES_HOME}/restart_counts.json).
Если сессия оставалась активной через 3+ перезапуска подряд, она автоматически
приостанавливается, чтобы пользователь получил чистый лист.
Пометка по таймауту drain
При штатном завершении или перезапуске система drain вызывает mark_resume_pending() для
каждой сессии, которая была посреди хода, когда сработал таймаут drain. Причины:
"restart_timeout"— убита во время drain при перезапуске"shutdown_timeout"— убита во время drain при завершении"restart_interrupted"— восстановление после сбоя для помеченного хода без ответа (изrecover_interrupted_turns)
Все три причины входят в _AUTO_RESUME_REASONS и дают право на автовозобновление при старте.
Автовозобновление при следующем обращении
Когда get_or_create_session() встречает resume_pending=True:
- Она возвращает существующую запись, не создавая новый
session_id. - Существующий транскрипт загружается целиком.
- Пометка здесь не снимается — она живёт до завершения следующего успешного хода
(
clear_resume_pending()вызывается из шлюза после того, какrun_conversation()вернёт настоящий ответ). - Если возобновлённый ход снова прервётся, флаг
resume_pendingостаётся, и следующий перезапуск повторит попытку. Терминальной эскалацией занимается счётчик зацикленных перезапусков (3 попытки → suspended).
Маркер чистого завершения (.clean_shutdown)
Пишется в конце штатного завершения. При следующем старте:
- Если он есть: восстановление после сбоя полностью пропускается, а осиротевшие маркеры ходов отбрасываются. Активные агенты уже были корректно завершены (drain), так что застрявших сессий нет.
- Затем маркер удаляется.
Это предотвращает нежелательные автосбросы после hermes update, hermes gateway restart
или /restart.
8. Очередь сообщений
Система очередей сообщений обрабатывает два сценария:
- Дополнения во время работы — когда пользователь шлёт несколько сообщений, пока агент обрабатывает запрос, последующие сообщения ставятся в очередь как одиночный слот ожидания.
- FIFO по
/queue— явные команды/queue, каждая из которых должна дать собственный полный ход агента, по порядку, без слияния.
Структуры данных
adapter._pending_messages: Dict[session_key, MessageEvent]
└── Один слот «следующее» на сессию. Перезаписывается при повторных
отправках (схлопывание серии). Общий с дополнениями к серии фото.
self._queued_events: Dict[session_key, List[MessageEvent]]
└── Буфер переполнения. Каждый вызов /queue дописывает сюда, когда
слот занят. Продвигается по одному после каждого drain.
Постановка в очередь (_enqueue_fifo)
_enqueue_fifo(session_key, event, adapter)
│
▼
┌───────────────────────────────────────┐
│ Is slot free? │
│ (session_key NOT in _pending_messages)│── Yes ──► Place event in slot
└───────────────────────────────────────┘
│ No
▼
Append to _queued_events[session_key] (overflow tail)
Извлечение / продвижение (_promote_queued_event)
Вызывается в точке drain после того, как слот был потреблён. Если в буфере переполнения есть элемент:
- Когда
pending_event is None(слот был пуст), голова буфера возвращается как новое событие. - Когда
pending_eventесть, голова буфера ставится в слот для следующей рекурсии. - Если адаптер недоступен, элемент возвращается обратно в
_queued_events(без тихой потери).
Глубина очереди
_queue_depth(session_key, adapter) возвращает len(overflow) + (1 if slot occupied else 0).
Очистка
События в очереди сессии очищаются по /new и /reset (через _handle_reset_command).
/stop сбрасывает одноместное продолжение, которое пользователь отправил во время
прерванного хода. Внутреннее пробуждение, припаркованное в любом из хранилищ
(уведомление о завершении асинхронного делегирования, notify+wake от kanban/cron),
переживает все три команды: _interrupt_and_clear_session оставляет его в слоте
(вытаскивая из переполнения, если слот занимало отброшенное продолжение от человека),
так что проход после команды сразу его запускает, а не оставляет сессию простаивать до
следующего сообщения пользователя. Может ли ещё выполниться пробуждение, привязанное к
сессии, которую только что закрыл /new, решается в момент обработки
(_resolve_async_delegation_session, fail-closed).
Обе команды к тому же завершают фоновые делегирования сессии (tools.async_delegation. interrupt_for_session, выбор по ключу маршрутизации и по устойчивому id сессии
породившего): _interrupt_and_clear_session рассылает остановку для пути занятой
сессии, а _handle_stop_command делает то же для простаивающей сессии, чей
диспетчеризующий ход уже закончился (и отвечает «Stopped», а не «No active task to
stop»). Собственное жёсткое прерывание хода до этих единиц не доходит — при
диспетчеризации их отцепляют от _active_children, — так что без рассылки они
доработали бы до конца и разбудили бы чат через несколько минут. Каждая остановленная
единица всё равно штатно завершается и возвращается уведомлением о завершении со
status="interrupted" и частичным выводом дочернего агента. /new и /reset уже
делали это в _handle_reset_command; более ранний вызов общего помощника там
идемпотентен (жёсткое прерывание, запрошенное дважды, — это одна остановка).
Инвариант FIFO
Каждый вызов /queue даёт ровно один полный ход агента, в порядке FIFO, без слияния. Схема
«одиночный слот _pending_messages + буфер переполнения _queued_events» гарантирует, что
повторные отправки во время активного хода не нарушат порядок обработки.
9. Внедрение контекста сессии
SessionContext строится из SessionSource и GatewayConfig и внедряется в системный
промпт агента. Он сообщает агенту:
- откуда пришло текущее сообщение;
- какие платформы подключены;
- куда можно доставить результаты запланированных задач;
- является ли сессия общей многопользовательской.
Построение (build_session_context)
def build_session_context(source, config, session_entry=None) -> SessionContext
- Собирает подключённые платформы из конфига.
- Собирает домашние каналы для каждой платформы.
- Определяет
shared_multi_user_sessionчерезis_shared_multi_user_session(). - Прикрепляет метаданные сессии (ключ, id, метки времени), если передан
session_entry.
Маскирование PII (build_session_context_prompt)
Динамическая секция системного промпта (## Current Session Context) может по желанию
маскировать персональные данные перед отправкой в LLM:
- ID пользователей →
user_<12hex>(префикс SHA-256) - ID чатов →
<platform>:<12hex>или просто<12hex> - Платформы, исключённые из маскирования: Discord (нужны сырые ID для
@mentions) и любая платформа, зарегистрированная плагином и не помеченная какpii_safe.
Маскирование касается только текста системного промпта. Маршрутизация, ключи сессий и операции адаптеров всегда используют исходные значения.
10. Фоновое обслуживание
_session_housekeeping_watcher периодически выметает простаивающих кэшированных агентов,
сбрасывает записи кэша при нехватке памяти и раз в час вычищает старые записи маршрутизации.
Из-за бездействия или времени суток он транскрипт никогда не завершает.
Вытеснение по TTL, LRU и давлению памяти сначала фиксирует живой транскрипт в провайдерах
памяти, затем мягко освобождает клиентов. Активные ходы остаются под защитой; ресурсы
терминала, браузера и фоновых процессов мягкое освобождение переживают. Вычистка записей
маршрутизации сохраняет канонический транскрипт в SQLite, а живые процессы защищают свои
записи маршрутизации от вычистки. Исторические флаги expiry_finalized остаются оградами
для восстановления, но таймерный наблюдатель их больше не пишет.
11. Кэш агентов
Шлюз держит LRU-кэш экземпляров AIAgent с ключом session_key, чтобы сохранять
кэширование промптов между ходами.
Свойства кэша
- Максимальный размер: 128 записей (
agent.agent_cache.max_size, по умолчанию_AGENT_CACHE_MAX_SIZE). - Политика вытеснения: давно не использовавшиеся (LRU через
OrderedDict). - TTL простоя: 3600 с (1 ч) —
agent.agent_cache.idle_ttl_secs, применяет_session_housekeeping_watcher. - Бюджет памяти:
agent.agent_cache.memory_high_mb(по умолчаниюauto) — см. ниже. - Блокировка:
_agent_cache_lock(threading) для потокобезопасности.
Вытеснение при нехватке памяти
Кэшированный агент удерживает _session_messages — полный живой транскрипт вместе с выводом
инструментов, а это десятки МБ на сессии со 100+ вызовами инструментов. Лимит числа записей
и TTL простоя этого не видят: шлюз, обслуживающий много чатов, держит в памяти каждый
«тёплый» транскрипт (агенты, сделавшие ход в пределах TTL, никогда не выметаются как
простаивающие), так что RSS растёт, пока cgroup не начнёт троттлить, а SIGTERM уже не успеет
сбросить данные в пределах stop-таймаута systemd (#80764).
Клапаном служит _sweep_agent_cache_under_pressure(). На каждом тике наблюдателя она
сравнивает анонимную память с memory_high_mb — берётся anon из memory.stat самой cgroup,
если шлюз работает под лимитом cgroup (это область, на которую списывается бюджет, поэтому
потомки в том же юните, например ядра execute_code, тоже считаются; #110549), иначе
анонимный RSS самого процесса. При превышении бюджета она вытесняет LRU-агентов через тот же
мягкий путь, что и ограничитель по числу записей (_commit_then_release_soft), а затем
вызывает malloc_trim, чтобы освобождённые арены действительно вернулись ОС. Вытесненные
сессии восстанавливают транскрипт из сохранённой сессии при следующем ходе.
Три класса сессий никогда не сбрасываются:
- агенты посреди хода (их клиенты и песочницы заняты);
protect_recentсамых недавно использованных сессий (их кэш промптов ценнее всего);- любая сессия, чей живой транскрипт ещё не дошёл до диска целиком —
transcript_persistence_caught_up()сравнивает_last_flushed_db_idxсlen(_session_messages); на то же расхождение реагирует защита от порчи записи FTS, когда сохраняет живую историю вместо отстающего транскрипта.
memory_high_mb: auto выводит бюджет из лимита cgroup, под которым работает шлюз
(memory.high, затем memory.max, затем cgroup v1), а без лимита берёт общий объём RAM.
Задайте число, чтобы зафиксировать бюджет, или 0/off, чтобы отключить этот проход
целиком. Хелперы лежат в gateway/agent_cache_pressure.py.
Жизненный цикл кэша
Message arrives
│
▼
get_or_create_session() → session_key obtained
│
▼
Lookup _agent_cache[session_key]
│
├── Hit → move_to_end(), reuse AIAgent (preserves prompt cache)
│
└── Miss → create new AIAgent, store in cache
(if at capacity, popitem(last=False) evicts LRU entry)
│
▼
run_conversation() → agent processes message
│
▼
Housekeeping soft-releases idle agents without ending transcripts
Порядок очистки
Вытеснение по ресурсам убирает кэшированного агента и фиксирует память перед мягким
освобождением клиентов. Полный демонтаж _cleanup_agent_resources(agent) приберегается для
настоящих границ разговора и завершения работы.
Приложение: ключи конфигурации
| Ключ конфига | Тип | По умолчанию | Описание |
|---|---|---|---|
group_sessions_per_user | bool | true | Изолировать сессии групп/каналов по пользователям |
thread_sessions_per_user | bool | false | Изолировать сессии тредов по пользователям |
session_store_max_age_days | int | 0 | Вычищать сессии старше N дней (0 = отключено) |
agent.gateway_auto_continue_freshness | int | 3600 | Окно свежести для возобновления, в секундах |
agent.gateway_timeout | int | 1800 | Таймаут хода агента (по умолчанию 30 мин) |
agent.agent_cache.max_size | int | 128 | Лимит LRU-записей кэшированных AIAgent |
agent.agent_cache.idle_ttl_secs | int | 3600 | Вытеснять агентов, простаивающих дольше этого |
agent.agent_cache.memory_high_mb | int/str | auto | Бюджет анонимного RSS, выше которого сбрасываются LRU-транскрипты |
agent.agent_cache.max_evictions_per_pass | int | 16 | Максимум сессий, сбрасываемых за один проход при нехватке памяти |
agent.agent_cache.protect_recent | int | 8 | Число MRU-сессий, которых проход при нехватке памяти не трогает |
База состояния и восстановление FTS
Канонический транскрипт живёт в таблицах sessions и messages. Таблицы FTS5 и их
триггеры синхронизации — производные индексы, которые можно отсоединить и перестроить, не
удаляя канонических сообщений. Ограниченный режим отказа в работе и явная процедура
восстановления описаны в Восстановление базы состояния.
Время жизни разговора
Настройки сброса по бездействию или по расписанию на день не поддерживаются. Явные /new и
/reset, компактизация, приостановка и восстановление после сбоя сохраняют свои отдельные
роли в жизненном цикле.