Дизайн: самовосстановление clientId при invalid_client_id (DaemonSessionClient)
- Дата: 2026-06-24
- Компонент:
packages/sdk-typescript—DaemonSessionClient - Зависит от: PR #5784 (
fix(daemon): Reject stale prompt client admission) — смержен (84745d0f0) - Статус: Реализовано (построено на основе смерженного #5784)
Проблема
После перезапуска демона (или перезагрузки сессии) регистрация клиентов в памяти демона очищается. Фронтенд, который все еще хранит старый назначенный сервером clientId, отправит POST /session/:id/prompt с этим устаревшим id. Метод resolveTrustedClientId в bridge не распознает его и отклоняет промпт с ошибкой InvalidClientIdError.
Зафиксированный производственный инцидент (trace a76a31fe…, лог демона 15:24): промпт был отправлен от client_d019b847, в то время как сессия была (пере)загружена под другим id client_ac36fac9, поэтому клиент, отправивший промпт, никогда не был зарегистрирован. UI бесконечно отображал статус «处理中», поскольку сбой не был передан как терминальное событие хода.
PR #5784 исправляет половину задачи, связанную с отображением ошибки: invalid_client_id теперь выбрасывается на этапе допуска, поэтому POST /session/:id/prompt возвращает синхронный ответ 400 invalid_client_id (без promptId) вместо 202 с последующим тихим асинхронным сбоем. Данный дизайн добавляет вторую половину — самовосстановление: когда SDK получает этот 400, он повторно регистрируется для получения нового clientId и повторяет отправку промпта один раз, чтобы ход продолжился без необходимости повторной отправки пользователем.
Область применения
В области применения (только SDK, DaemonSessionClient):
- Обнаружение
invalid_client_idпри вызове допуска промпта. - Повторная регистрация клиента в (уже восстановленной) сессии для получения нового назначенного сервером
clientId. - Повторная отправка промпта один раз с новым
clientId.
Явно вне области применения (YAGNI):
- Переподключение SSE-потока — остается существующей обязанностью слоя приложения (приложение dataworks уже владеет логикой
reloadSession/переподключения).invalid_client_idпроявляется только при вызове допуска, никогда при ожидании SSE. - Самовосстановление для других методов, использующих
clientId(btw,shell, сообщение в середине хода,cancel,heartbeat). Самовосстановление работает только дляprompt(). - Сохранение
clientIdмежду перезапусками демона.
Ключевые инварианты (проверено по исходному коду)
-
Повторная попытка безопасна, потому что
invalid_client_id— это отклонение на этапе допуска.resolveTrustedClientIdвыполняется внутриbridge.sendPromptдо регистрации хода и до того, как роутер вернет202. С PR #5784 это выбрасывает исключение синхронно →400до принятия → промпт никогда не выполняется. Следовательно, повторная попытка не может привести к двойному выполнению сообщения пользователя. Этот инвариант является всей основой безопасности повторной попытки; он зависит от #5784. -
registerClientникогда не выбрасывает исключение и всегда возвращает валидный id. Для неизвестногоrequestedClientIdон переходит кcreateClientId()и возвращает новыйclient_<uuid>. Исключение выбрасывает толькоresolveTrustedClientId(используется в prompt/cancel/…). Поэтому вызовload/resumeвсегда возвращает пригодный для использованияclientId. -
Ответ при восстановлении всегда содержит зарегистрированный
clientId. И быстрый путь для существующей записи, и путь холодного восстановления устанавливаютclientId: registerClient(entry, req.clientId)в ответе. (Примечание “возвращается только если вызывающий код передал clientId” вtypes.tsотносится кHeartbeatResult, а не к восстановлению.) -
Отсутствие чистых утечек attach в сценарии перезапуска, а также повышение корректности
close().resumeSessionвыполняетattachCount++. Уменьшение счетчика ссылок происходит через/detach→detachClient(attachCount--+unregisterClient).close()→DELETE /session/:id→closeSessionImplработает по принципу уничтожить всё: он валидирует clientId черезresolveTrustedClientId, а затем разрушает сессию (byId.delete), сбрасывая вместе с нейattachCount. Перезапуск демона очищает attach до перезапуска;reattach()восстанавливает ровно один attach, а последующийclose()/перезапуск уничтожает всё целиком — чистых утечек нет. Обратите внимание, чтоcloseSessionImplтакже валидирует clientId, поэтому до этого измененияclose()после перезапуска с устаревшим id сам бы выбрасывалInvalidClientIdError; послеreattach(), вызванного промптом,this.clientIdстановится валидным, иclose()успешно завершается. (Самclose()не подвержен самовосстановлению — это вне области применения — но получает косвенную выгоду.) -
Изменение инертно без PR #5784. Демон до версии #5784 возвращает
202с последующим асинхронным сбоем, но никогда400 invalid_client_id, поэтому предикат никогда не срабатывает и самовосстановление не запускается. Безопасная холостая операция (no-op).
Дизайн
Все изменения ограничены файлом packages/sdk-typescript/src/daemon/DaemonSessionClient.ts.
1. isInvalidClientId(err): boolean
function isInvalidClientId(err: unknown): boolean {
return (
err instanceof DaemonHttpError &&
err.status === 400 &&
typeof err.body === 'object' &&
err.body !== null &&
(err.body as { code?: unknown }).code === 'invalid_client_id'
);
}Требуется импорт DaemonHttpError из ./DaemonHttpError.js.
2. reattach(): Promise<void> — single-flight
private reattaching?: Promise<void>;
private async reattach(): Promise<void> {
// Coalesce concurrent prompts that all observed invalid_client_id so we
// re-register exactly once (avoids orphaning extra clientIds / attachCount).
if (this.reattaching) return this.reattaching;
this.reattaching = (async () => {
// Pass no clientId so the bridge issues a fresh registration instead of
// validating the stale one. Pass workspaceCwd explicitly: restoreSession
// calls resolveWorkspaceKey(req.workspaceCwd) before the existing-entry
// fast path, and that helper throws on a non-absolute/undefined path.
const { clientId } = await this.client.resumeSession(
this.sessionId,
{ workspaceCwd: this.workspaceCwd },
undefined,
);
this.session.clientId = clientId; // only refresh clientId; leave the SSE
// cursor (lastSeenEventId) and state alone
})();
try {
await this.reattaching;
} finally {
this.reattaching = undefined;
}
}this.session — это поверхностная копия, а DaemonSession.clientId не является readonly, поэтому мутация на месте допустима. Используется resume (а не load), потому что нам нужна только повторная регистрация, а не воспроизведение истории.
3. withClientIdSelfHeal<T>(fn): Promise<T>
private async withClientIdSelfHeal<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (!isInvalidClientId(err)) throw err; // non-invalid_client_id: propagate
await this.reattach(); // may throw → propagate
return await fn(); // retry exactly once; if it throws
// again (incl. invalid_client_id),
// propagate — no loop
}
}4. Интеграция в prompt()
Оберните только сетевой вызов допуска на обоих путях; оставьте reservePromptSlot/releaseAdmission вне обертки, чтобы локальный слот резервировался один раз и переиспользовался при повторной попытке:
- Блокирующий путь (
!this.subscriptionActive):return await this.withClientIdSelfHeal(() => this.client.prompt(this.sessionId, req, signal, this.clientId)); - Неблокирующий путь:
accepted = await this.withClientIdSelfHeal(() => this.client.promptNonBlocking(this.sessionId, req, signal, this.clientId));
this.clientId считывается внутри замыкания, поэтому при повторной попытке подхватывается обновленный id. Всё, что происходит после допуска (регистрация _pendingPrompts и сопоставление событий хода SSE по promptId), остается без изменений; подписка SSE привязана к sessionId, поэтому она переживает изменение clientId.
Обработка ошибок
- Ошибки, отличные от
invalid_client_id(например,500,SessionNotFoundError,DaemonPendingPromptLimitError): пробрасываются немедленно, безreattach. - Сбой
reattach()(сессия действительно удалена, проблемы с сетью): пробрасывается — пользователь видит реальную ошибку вместо зависания. - Исчерпание попыток (повторная попытка также вернула
invalid_client_id): пробрасывается; ограничено одной повторной попыткой, без цикла. AbortSignal: обернутые вызовыprompt/promptNonBlockingвызываютthrowIfAborted()при входе, поэтому повторная попытка после прерывания выбрасываетAbortError. (resumeSessionне имеет параметра signal; выполняющийсяreattachнельзя прервать — это приемлемо, так как это один короткий вызов.)
Известные ограничения
- Редкий пограничный случай с индивидуальным вытеснением: если
clientIdвытесняется, пока сессия остается активной в памяти (отзыв при утечке /client_evicted),reattach()добавляет лишний attach (attachCount++) без соответствующего/detach. Посколькуclose()работает по принципу “уничтожить всё”, единственное окно утечки — это сессия, которая была заброшена без явногоclose()и затем защищена от idle-GC застрявшимattachCount(ограничено одной сессией). Реалистичный инцидент — это случай перезапуска демона, который обрабатывается чисто. Задокументировано, а не спроектировано для обхода.
Тестирование (TDD)
Используйте существующий харнесс recordingFetch в packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts, перехватывая запросы по URL через реальный DaemonClient (который задействует реальный маппинг failOnError → DaemonHttpError).
- Неблокирующее самовосстановление: первый
POST /session/s-1/prompt→400 {code:'invalid_client_id'};POST /session/s-1/resume→ новыйclientId: 'client-2'; второй промпт →202. Утверждение: промпт успешно завершается, второй запрос промпта содержитx-qwen-client-id: client-2, resume вызван один раз. - Блокирующее самовосстановление (
subscriptionActivefalse): то же самое, через блокирующий путьprompt(200/202+ завершение хода при повторной попытке). - Ограничение повторных попыток: промпт →
400 invalid_client_idдважды → ошибка пробрасывается (утверждение: resume вызван один раз, ошибка —DaemonHttpErrorinvalid_client_id). - Невалидные ошибки не повторяются: промпт →
500→ пробрасывается немедленно,resumeникогда не вызывается. - Проброс сбоя reattach: промпт →
400 invalid_client_id; resume →404/500→ эта ошибка пробрасывается. - Single-flight: два одновременных вызова
prompt()оба получают400 invalid_client_id→resumeвызывается ровно один раз; обе повторные попытки используют новый id.