Skip to Content
ДизайнSession Idle ReaperSession Idle Reaper — Дизайн-документ

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>) бесконечно. Она уничтожается только в следующих случаях:

  1. Клиент явно вызывает DELETE /session/:id (closeSession)
  2. Общий дочерний процесс qwen --acp падает (обработчик channel.exited)
  3. Процесс демона получает 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. Цели дизайна

  1. Автоматически освобождать простаивающие сессии, клиенты которых исчезли и у которых нет активной работы.
  2. Никогда не уничтожать сессию с активным prompt’ом — это молча убило бы видимую пользователю работу.
  3. Сохранять персистентные данные сессии — освобождается только состояние моста в памяти; транскрипты на диске (SessionService) не трогаются. Пользователи могут восстановить сессию через session/load или session/resume.
  4. Наблюдаемость — генерировать отдельное SSE-событие, чтобы клиенты знали, ПОЧЕМУ закрылась сессия (тайм-аут простоя vs. явное закрытие vs. сбой).
  5. Конфигурируемость — операторы и тесты могут настраивать тайм-ауты или полностью отключать reaper.
  6. Без новых зависимостей / компонентов — реализовать полностью внутри существующего замыкания моста.

Нецели

  • Управление сессиями между рабочими пространствами (это задача шлюза).
  • 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 sweepACP-over-HTTP соединениеВытесняет соединения транспортного уровня /acp (другой уровень)
writerIdleTimeoutMsSSE-подписчикВытесняет одного «зависшего» SSE-подписчика
Disconnect reaper (server.ts)Ручка spawnВытесняет сессии, чей владелец spawn’а отключился ВО ВРЕМЯ POST /session handshake

Два механизма работают вместе, чтобы покрыть жизненный цикл сессии:

  1. Close-on-last-detach (основной) — когда detachClient удаляет последнего зарегистрированного клиента И не остаётся подписчиков SSE, сессия закрывается немедленно через closeSessionImpl. Это обрабатывает нормальный путь: пользователь закрывает вкладку → React cleanup → POST /session/:id/detach.

  2. 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 Предикат простоя сессии

Сессия подлежит вытеснению, если выполняются все следующие условия:

  1. Нет активного prompt’а: entry.promptActive === false
  2. Нет живых подписчиков SSE: entry.events.subscriberCount === 0
  3. Превышена длительность простоя: 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, который:

  1. Удаляет из byId / defaultEntry
  2. Отменяет ожидающие разрешения через permissionMediator.forgetSession
  3. Публикует событие session_closedreason: 'idle_timeout')
  4. Закрывает EventBus
  5. Отправляет connection.cancel() дочернему процессу ACP (best-effort)
  6. Запускает 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 отложен. Внутри closeSession byId.delete выполняется ПОСЛЕ первого await (notifyAgentSessionClose). Это означает, что удаления происходят в микрозадачах после завершения цикла for...of. Поскольку каждый closeSession работает с отдельным ключом, алиасинга нет. И for...of уже закончил итерацию, поэтому удаление посреди итерации не является проблемой.
  • Гонка двойного закрытия. Если клиент вызывает DELETE /session/:id для той же сессии между проверкой предиката reaper’а и асинхронным выполнением closeSession, то closeSession reaper’а выбросит 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, продвинуть время, проверить, что сессия переживает
5Reaper отключён, когда interval = 0Передать sessionReapIntervalMs: 0, проверить, что setInterval не активирован
6Reaper отключён, когда timeout = 0Передать sessionIdleTimeoutMs: 0, проверить, что setInterval не активирован
7Reaper останавливается при shutdownВызвать shutdown(), проверить, что clearInterval был вызван
8Причина closeSession по умолчанию — ‘client_close’Вызвать closeSession без явной причины, проверить, что опубликованное событие имеет reason: 'client_close'
9closeSession с явной причинойВызвать closeSession с reason: 'idle_timeout', проверить опубликованное событие
10Несколько простаивающих сессий вытесняются за один тикСоздать 3 простаивающие сессии, продвинуть время, запустить тик, проверить, что все 3 вытеснены
11Сессия с heartbeat в пределах TTL переживаетСоздать сессию, записать heartbeat, продвинуть время чуть меньше TTL, проверить, что сессия переживает
12Таймер простоя канала запускается после вытеснения последней сессииСоздать 1 сессию (последнюю на канале), вытеснить её, проверить, что startIdleTimer вызван на канале

5.2 Интеграционные тесты (server.test.ts)

#ТестОписание
1GET /health?deep=1 отражает очищенное reaper’ом количество сессийЗапустить демон, создать сессии, продвинуть время, проверить, что эндпоинт health показывает уменьшенное количество
2Подписчик SSE получает session_closed с reason: 'idle_timeout'Открыть SSE, отключиться, переподключиться до TTL, затем дать TTL истечь, проверить событие

6. Значения по умолчанию для конфигурации

ОпцияЗначение по умолчаниюОбоснование
sessionReapIntervalMs60 000 (1 мин)Достаточно часто, чтобы предотвратить длительное накопление, достаточно дёшево (простой обход Map) для частого запуска
sessionIdleTimeoutMs1 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 последовательных окон heartbeatP2

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 для канала. Дополнительной логики не требуется.
Last updated on