Session Idle Reaper — Дизайн-документ
Статус: Черновик
Автор: qinqi
Дата: 2026-06-08
Область: packages/acp-bridge/src/bridge.ts, packages/cli/src/serve/server.ts
1. Постановка задачи
1.1 Текущее поведение
После создания сессия моста живёт в памяти (byId: Map<string, SessionEntry>) бесконечно. Она уничтожается только в следующих случаях:
- Клиент явно вызывает
DELETE /session/:id(closeSession) - Общий дочерний процесс
qwen --acpпадает (обработчикchannel.exited) - Процесс демона получает
SIGTERM/SIGINT(shutdown)
Автоматического тайм-аута простоя для сессий нет. Отметки времени heartbeat (sessionLastSeenAt, clientLastSeenAt) записываются в recordHeartbeat, но никогда не используются для вытеснения (в комментарии к полю упоминается будущая «политика отзыва (PR 24)», которая так и не была реализована).
1.2 Влияние
| Сценарий | Симптом |
|---|---|
Пользователь открывает несколько вкладок браузера, закрывает их без вызова DELETE /session | Сессии накапливаются в byId, каждая держит кольцевой буфер EventBus (~2–4 МБ) |
Накапливается 20 сессий (по умолчанию maxSessions) | SessionLimitExceededError при новом spawnOrAttach — пользователь заблокирован |
| Долгоживущий демон с частой сменой вкладок | Неограниченный рост памяти в кольцевых буферах EventBus и состоянии сессии на стороне ACP |
| Расширение IDE перезапускается / падает | «Потерянные» сессии никогда не очищаются |
1.3 Почему сейчас
Демон всё чаще используется как долгоживущий сервер рабочего пространства (десктопное приложение, расширения IDE, веб-интерфейс). Сбои клиента и сетевые «моргания» — обычное дело; полагаться на явный DELETE для очистки неприемлемо.
2. Цели дизайна
- Автоматически освобождать простаивающие сессии, клиенты которых исчезли и у которых нет активной работы.
- Никогда не уничтожать сессию с активным prompt’ом — это молча убило бы видимую пользователю работу.
- Сохранять персистентные данные сессии — освобождается только состояние моста в памяти; транскрипты на диске (
SessionService) не трогаются. Пользователи могут восстановить сессию черезsession/loadилиsession/resume. - Наблюдаемость — генерировать отдельное SSE-событие, чтобы клиенты знали, ПОЧЕМУ закрылась сессия (тайм-аут простоя vs. явное закрытие vs. сбой).
- Конфигурируемость — операторы и тесты могут настраивать тайм-ауты или полностью отключать reaper.
- Без новых зависимостей / компонентов — реализовать полностью внутри существующего замыкания моста.
Нецели
- Управление сессиями между рабочими пространствами (это задача шлюза).
- LRU-вытеснение при достижении
maxSessions(ценная, но отдельная работа — отслеживается как follow-up). - Сжатие кольцевого буфера EventBus для простаивающих сессий (низкий приоритет при лимите в 20 сессий; отслеживается как follow-up).
- Адаптивное давление на основе RSS (требует опроса
process.memoryUsage()и разработки политики; отслеживается как follow-up).
3. Архитектура
3.1 Обзор
Замыкание моста (createHttpAcpBridge)
│
├─ byId: Map<sessionId, SessionEntry> ← существующий
├─ channelInfo: ChannelInfo ← существующий
├─ idleTimer (на уровне канала) ← существующий
│
└─ sessionReaper: NodeJS.Timeout ← НОВЫЙ
│
├─ сканирует byId каждые REAP_INTERVAL_MS
├─ пропускает сессии с активным prompt'ом
├─ пропускает сессии с живыми подписчиками SSE
├─ закрывает сессии, превысившие idle TTL
└─ отправляет session_closed { reason: 'idle_timeout' }3.2 Отношение к существующим механизмам
| Механизм | Область | Что управляет |
|---|---|---|
channelIdleTimeoutMs + startIdleTimer | Канал (дочерний процесс) | Убивает дочерний qwen --acp, когда ВСЕ сессии исчезли |
| Session reaper (этот дизайн) | Сессия (запись в памяти) | Закрывает отдельные сессии при простое |
ConnectionRegistry sweep | ACP-over-HTTP соединение | Вытесняет соединения транспортного уровня /acp (другой уровень) |
writerIdleTimeoutMs | SSE-подписчик | Вытесняет одного «зависшего» SSE-подписчика |
| Disconnect reaper (server.ts) | Ручка spawn | Вытесняет сессии, чей владелец spawn’а отключился ВО ВРЕМЯ POST /session handshake |
Два механизма работают вместе, чтобы покрыть жизненный цикл сессии:
-
Close-on-last-detach (основной) — когда
detachClientудаляет последнего зарегистрированного клиента И не остаётся подписчиков SSE, сессия закрывается немедленно черезcloseSessionImpl. Это обрабатывает нормальный путь: пользователь закрывает вкладку → React cleanup →POST /session/:id/detach. -
Session idle reaper (запасной) — периодическое сканирование сессий без активного prompt’а и без подписчиков SSE, которые не получали heartbeat в течение настроенного TTL. Это ловит путь сбоя: браузер убит, сеть упала,
kill -9— запрос detach никогда не был отправлен, поэтомуclientIdsвсё ещё показывает зарегистрированных клиентов, но сессия фактически «потеряна».
4. Детальный дизайн
4.1 Новые параметры конфигурации (BridgeOptions)
interface BridgeOptions {
// ... существующие поля ...
/**
* Как часто session reaper сканирует `byId` на предмет простаивающих сессий,
* в миллисекундах. По умолчанию: 60_000 (1 минута). Установите 0 или Infinity,
* чтобы отключить reaper полностью. Таймер `.unref()`'нут.
*/
sessionReapIntervalMs?: number;
/**
* Сессия с НУЛЕВЫМ количеством живых подписчиков SSE И НУЛЕВЫМ количеством
* зарегистрированных клиентов, не получавшая heartbeat в течение этого
* количества миллисекунд, считается простаивающей и будет вытеснена.
*
* По умолчанию: 30 * 60_000 (30 минут).
* Установите 0 или Infinity, чтобы отключить вытеснение по простою.
*/
sessionIdleTimeoutMs?: number;
}CLI-поверхность (флаги qwen serve):
--session-reap-interval-ms <ms> Интервал сканирования reaper (по умолчанию 60000, 0=отключить)
--session-idle-timeout-ms <ms> Порог простоя (по умолчанию 1800000, 0=отключить)4.2 Предикат простоя сессии
Сессия подлежит вытеснению, если выполняются все следующие условия:
- Нет активного prompt’а:
entry.promptActive === false - Нет живых подписчиков SSE:
entry.events.subscriberCount === 0 - Превышена длительность простоя:
now - lastActivity(entry) > sessionIdleTimeoutMs
Примечание: reaper намеренно НЕ проверяет clientIds.size. Он покрывает путь сбоя, когда detach не был отправлен — clientIds всё ещё показывает зарегистрированных клиентов, но сессия фактически потеряна. Нормальный путь (клиент отправляет detach) обрабатывается close-on-last-detach.
Где lastActivity(entry) определяется как:
function lastActivity(entry: SessionEntry): number {
// `sessionLastSeenAt` — это эпохальные миллисекунды (от Date.now());
// `createdAt` — строка ISO 8601 — парсим в эпохальные миллисекунды как запасной вариант.
return entry.sessionLastSeenAt ?? Date.parse(entry.createdAt);
}Примечание: entry.createdAt имеет тип string (ISO 8601), а не число.
Date.parse здесь безопасен — формат всегда new Date().toISOString() (см. createSessionEntry, bridge.ts:1883).
Обоснование каждой проверки:
| Проверка | Почему |
|---|---|
| Нет активного prompt’а | Головной / автономный prompt (например, конвейер CLI, задача cron) может выполняться без подписчика SSE. Его вытеснение убило бы работу. |
| Нет подписчиков SSE | Подключённый клиент активно слушает. Даже если он не отправлял heartbeat, само SSE-соединение доказывает «живость». |
| Длительность простоя | Льготный период, чтобы временно отключившиеся клиенты могли переподключиться без потери сессии. |
4.3 Действие при вытеснении
Для каждой сессии, прошедшей предикат простоя, reaper вызывает:
await closeSession(sessionId, { reason: 'idle_timeout' });Это использует существующий путь closeSession, который:
- Удаляет из
byId/defaultEntry - Отменяет ожидающие разрешения через
permissionMediator.forgetSession - Публикует событие
session_closed(сreason: 'idle_timeout') - Закрывает EventBus
- Отправляет
connection.cancel()дочернему процессу ACP (best-effort) - Запускает
startIdleTimerна канале, если это была последняя сессия
Почему closeSession, а не killSession?
killSession — это внутренний путь принудительного вытеснения, предназначенный для гонки отключения при spawn-handshake (requireZeroAttaches guard, spawnOwnerWantedKill tombstone). closeSession — это документированный клиентский путь, который публикует session_closed (не session_died) и правильно обрабатывает телеметрию. Reaper — это «корректное закрытие от имени отсутствующего клиента», поэтому closeSession — правильная семантика.
4.4 Расширение closeSession для приёма причины закрытия
В настоящее время closeSession жёстко задаёт reason: 'client_close' в событии session_closed. Нам нужно сделать этот параметр настраиваемым.
Подход: Добавить новый опциональный параметр opts в closeSession, а не перегружать BridgeClientRequestContext (который является типом, ограниченным областью клиентского запроса — добавление reason в него было бы нарушением слоёв, так как «причина» — это решение на стороне сервера, а не то, что клиент передаёт в заголовке).
// bridgeTypes.ts — новый тип + изменение сигнатуры:
export interface CloseSessionOpts {
/** Переопределяет значение по умолчанию 'client_close' в событии session_closed. */
reason?: string;
}
closeSession(
sessionId: string,
context?: BridgeClientRequestContext,
opts?: CloseSessionOpts,
): Promise<void>;// bridge.ts — изменение реализации:
async closeSession(sessionId, context, opts) {
// ...
const reason = opts?.reason ?? 'client_close';
entry.events.publish({
type: 'session_closed',
data: { sessionId, reason, ... },
});
}Существующие вызывающие стороны (маршрут DELETE /session/:id) не передают opts, по умолчанию получая 'client_close'. Reaper передаёт { reason: 'idle_timeout' }.
4.5 Жизненный цикл Reaper
// Внутри замыкания createHttpAcpBridge:
const resolvedReapIntervalMs = resolvePositiveMs(
opts.sessionReapIntervalMs,
60_000,
);
const resolvedIdleTimeoutMs = resolvePositiveMs(
opts.sessionIdleTimeoutMs,
30 * 60_000,
);
let sessionReaper: ReturnType<typeof setInterval> | undefined;
function startSessionReaper(): void {
if (resolvedReapIntervalMs <= 0 || resolvedIdleTimeoutMs <= 0) return;
sessionReaper = setInterval(() => {
if (shuttingDown) return;
const now = Date.now();
for (const [id, entry] of byId) {
if (entry.promptActive) continue;
if (entry.events.subscriberCount > 0) continue;
const lastActive = entry.sessionLastSeenAt ?? Date.parse(entry.createdAt);
const idle = now - lastActive;
if (idle < resolvedIdleTimeoutMs) continue;
writeStderrLine(
`qwen serve: reaping idle session ${JSON.stringify(id)} ` +
`(idle for ${Math.round(idle / 1000)}s, threshold ${Math.round(resolvedIdleTimeoutMs / 1000)}s)`,
);
// Передаём `undefined` контекст (нет клиента) и опции `{ reason }`.
bridgeImpl
.closeSession(id, undefined, { reason: 'idle_timeout' })
.catch((err) => {
writeStderrLine(
`qwen serve: session reaper failed to close ${JSON.stringify(id)}: ${String(err)}`,
);
});
}
}, resolvedReapIntervalMs);
sessionReaper.unref();
}
function stopSessionReaper(): void {
if (sessionReaper !== undefined) {
clearInterval(sessionReaper);
sessionReaper = undefined;
}
}Примечание: bridgeImpl ссылается на объект моста, возвращаемый createHttpAcpBridge, так что closeSession имеет полный доступ к состоянию в области замыкания. На практике это реализовано как прямой вызов внутренней функции closeSessionImpl в замыкании.
Интеграция жизненного цикла:
startSessionReaper()вызывается при создании моста (после валидации опций, вместе с существующей настройкойchannelIdleTimeoutMs).stopSessionReaper()вызывается в обоихshutdown()иkillAllSync().
4.6 Взаимодействие с существующими вызывающими сторонами closeSession
| Вызывающая сторона | Влияние |
|---|---|
Маршрут DELETE /session/:id | Нет — opts не передаётся, по умолчанию reason: 'client_close' |
| Session reaper (этот дизайн) | Передаёт opts: { reason: 'idle_timeout' } |
Отложенное вытеснение detachClient | Вызывает killSession (не closeSession), без изменений |
Обработчик channel.exited | Публикует session_died, без изменений |
shutdown() | Публикует session_died с причиной daemon_shutdown, без изменений |
4.7 Безопасность параллелизма
Колбэк reaper’а выполняется в цикле событий Node.js. Ключевые моменты:
- Итерация
for...ofсинхронна. Reaper оценивает предикат простоя для каждой записи синхронно, затем запускаетcloseSession(...).catch(...)для подходящих записей. В теле цикла нетawait— все закрытия отправляются за одну границу микрозадачи, после чего цикл завершается. byId.deleteотложен. ВнутриcloseSessionbyId.deleteвыполняется ПОСЛЕ первогоawait(notifyAgentSessionClose). Это означает, что удаления происходят в микрозадачах после завершения циклаfor...of. Поскольку каждыйcloseSessionработает с отдельным ключом, алиасинга нет. Иfor...ofуже закончил итерацию, поэтому удаление посреди итерации не является проблемой.- Гонка двойного закрытия. Если клиент вызывает
DELETE /session/:idдля той же сессии между проверкой предиката reaper’а и асинхронным выполнениемcloseSession, тоcloseSessionreaper’а выброситSessionNotFoundError(перехватывается.catch()). Безопасно. - Гонка переподключения. Если клиент переподключается к сессии (регистрирует clientId / открывает SSE) между проверкой предиката reaper’а и выполнением
closeSession, тоcloseSessionвсё равно продолжится и закроет сессию. Клиент получаетsession_closedи должен перезагрузить сессию. Это окно крайне узкое (один синхронный тикsetInterval), и последствия легкие — нет потери данных, только повторная загрузка. Значение TTL по умолчанию в 30 минут делает это событие чрезвычайно редким. - Параллельный
spawnOrAttach, создающий новую сессию во время сканирования reaper’ом, не будет замечен (мы итерируем записиbyIdна момент начала каждого тика). Это безопасно — новые сессии свежие и не удовлетворят порогу простоя.
4.8 Изменение формата проводов
Поле data.reason события session_closed уже существует со значением 'client_close'. Мы добавляем два новых значения:
'idle_timeout'— генерируется reaper’ом простоя (запасной вариант для упавших клиентов)'last_client_detached'— генерируется close-on-last-detach (нормальное закрытие вкладки)
Это обратно совместимо — существующий SDK, проверяющий reason === 'client_close', просто не совпадёт с новыми значениями, а общий обработчик терминальных событий (isTerminalLifecycleEvent) уже обрабатывает session_closed независимо от причины.
5. План тестирования
5.1 Модульные тесты (bridge.test.ts)
| # | Тест | Описание |
|---|---|---|
| 1 | Простаивающая сессия вытесняется после тайм-аута | Создать сессию, продвинуть время за sessionIdleTimeoutMs, запустить тик reaper’а, проверить, что сессия удалена из byId и опубликовано событие session_closed с reason: 'idle_timeout' |
| 2 | Сессия с активным prompt’ом НЕ вытесняется | Создать сессию, запустить prompt, продвинуть время, проверить, что сессия переживает тик reaper’а |
| 3 | Сессия с живым подписчиком SSE НЕ вытесняется | Создать сессию, подписаться на её EventBus, продвинуть время, проверить, что сессия переживает |
| 4 | Сессия с зарегистрированным клиентом НЕ вытесняется | Создать сессию, зарегистрировать clientId, продвинуть время, проверить, что сессия переживает |
| 5 | Reaper отключён, когда interval = 0 | Передать sessionReapIntervalMs: 0, проверить, что setInterval не активирован |
| 6 | Reaper отключён, когда timeout = 0 | Передать sessionIdleTimeoutMs: 0, проверить, что setInterval не активирован |
| 7 | Reaper останавливается при shutdown | Вызвать shutdown(), проверить, что clearInterval был вызван |
| 8 | Причина closeSession по умолчанию — ‘client_close’ | Вызвать closeSession без явной причины, проверить, что опубликованное событие имеет reason: 'client_close' |
| 9 | closeSession с явной причиной | Вызвать closeSession с reason: 'idle_timeout', проверить опубликованное событие |
| 10 | Несколько простаивающих сессий вытесняются за один тик | Создать 3 простаивающие сессии, продвинуть время, запустить тик, проверить, что все 3 вытеснены |
| 11 | Сессия с heartbeat в пределах TTL переживает | Создать сессию, записать heartbeat, продвинуть время чуть меньше TTL, проверить, что сессия переживает |
| 12 | Таймер простоя канала запускается после вытеснения последней сессии | Создать 1 сессию (последнюю на канале), вытеснить её, проверить, что startIdleTimer вызван на канале |
5.2 Интеграционные тесты (server.test.ts)
| # | Тест | Описание |
|---|---|---|
| 1 | GET /health?deep=1 отражает очищенное reaper’ом количество сессий | Запустить демон, создать сессии, продвинуть время, проверить, что эндпоинт health показывает уменьшенное количество |
| 2 | Подписчик SSE получает session_closed с reason: 'idle_timeout' | Открыть SSE, отключиться, переподключиться до TTL, затем дать TTL истечь, проверить событие |
6. Значения по умолчанию для конфигурации
| Опция | Значение по умолчанию | Обоснование |
|---|---|---|
sessionReapIntervalMs | 60 000 (1 мин) | Достаточно часто, чтобы предотвратить длительное накопление, достаточно дёшево (простой обход Map) для частого запуска |
sessionIdleTimeoutMs | 1 800 000 (30 мин) | Щедрый период ожидания для переподключения. Соответствует ConnectionRegistry.idleTtlMs для единообразия модели мышления |
7. Наблюдаемость
- Журнал stderr:
qwen serve: reaping idle session "<id>" (idle for Nms)при каждом сборе, соответствует существующей конвенции префиксаqwen serve:. - Событие телеметрии:
session.closeс операциейqwen-code.daemon.bridge.operation: 'session.close'(использует существующий путь телеметрииcloseSession). - Метрика телеметрии:
sessionLifecycle('close')(использует существующий счётчик). - SSE-событие:
session_closedсdata.reason: 'idle_timeout'.
8. Дальнейшая работа (за рамками текущей задачи)
| Пункт | Описание | Приоритет |
|---|---|---|
LRU-вытеснение при maxSessions | Вместо отклонения новых сессий — вытеснять наименее активную бездействующую сессию | P1 |
| Уплотнение кольца EventBus | Сжать кольцо для сессий с 0 подписчиков для экономии памяти | P2 |
| Адаптивное давление на основе RSS | Мониторить process.memoryUsage().rss и уменьшать TTL бездействия при нехватке памяти | P2 |
| Определение активности клиента через heartbeat | Автоматически удалять клиентов, пропустивших N последовательных окон heartbeat | P2 |
9. Риски и меры по их снижению
| Риск | Меры по снижению |
|---|---|
| Сборщик закрывает сессию, к которой headless-клиент собирается переподключиться | 30-минутный TTL по умолчанию является щедрым; headless-клиенты должны отправлять heartbeats. Дисковая расшифровка сохраняется — session/load восстанавливает её. |
closeSession внутри сборщика вызывает исключение, нарушая цикл обхода | Каждое закрытие выполняется в отдельном .catch() — сбой одного не блокирует другие |
Обход сборщиком byId во время одновременного closeSession из другого пути | Итерация Map ES2015 допускает удаление текущего/предыдущего ключей. Двойное закрытие идемпотентно (byId.get возвращает undefined → SessionNotFoundError, перехватываемый .catch() сборщика). |
| Производительность при обходе 20 сессий каждые 60 с | Тривиально — 20 чтений Map + проверка 4 полей каждое. Никакого ввода-вывода. |
| Взаимодействие с таймером бездействия канала | Когда последняя сессия собрана, closeSession уже вызывает startIdleTimer для канала. Дополнительной логики не требуется. |