fix(ai-chat): реактивная рекавери — итеративная эскалация 0.5^k + пол вместо фикс. 0.5× (#520) #546
Reference in New Issue
Block a user
Delete Branch "fix/520-recovery-escalation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Follow-up к #490/#510: реактивная рекавери после context-overflow 400 была фиксированной долей
0.5×от булеваreplayOverflow— не эскалировала. При НЕзаданномchatContextWindow(плоский дефолт 100k) и реально малом окне модели (<50k) даже0.5×100k=50kпереполняло снова, ход перештамповывался, но булев был ужеtrue→ второго ужесточения нет. Остаточный кирпич: чат застревал на 50k каждый ход. closes #520.Фикс (итеративная эскалация + пол)
replayOverflow→ счётчик последовательных overflowmetadata.replayOverflowCount(k). Инкремент:priorOverflowCount + 1(стрик читается в начале хода). Сброс: чистый finalize опускает поле → читатель берёт самый свежий assistant-row → отсутствие = k=0 (авто-сброс). Back-compat: legacyreplayOverflow:trueбез счётчика → k=1 (старое 0.5× для in-flight чатов); отрицательное/NaN → 0.resolveEffectiveReplayThreshold(threshold, k) = max(floor(threshold · 0.5^k), min(REPLAY_MIN_FLOOR_TOKENS, threshold)). k=1→0.5×, k=2→0.25×, … сходится/клэмпится наREPLAY_MIN_FLOOR_TOKENS = 8_000.min(floor, threshold)— чтобы пол НЕ поднимал легитимно малый сконфигурированный бюджет выше себя (иначе переполнил бы своё же окно).null/k=0 → passthrough (off-switch не переопределяется).How verified (реальный pg)
base=100k, realWindow=40k→ утверждает СХОДИМОСТЬ ([2,3,4,5].some(k → threshold<40k)), а не «≠50k». Мутация**k → **1краснит 5 тестов (un-bricks + floor-clamp).isContextOverflowError/resolveReplayBudget/keep-recent-turns не тронуты.apps/server/src/core/ai-chat/.Осознанный трейд-офф (INFO из ревью)
Маленький сконфигурированный бюджет (threshold<8k) эскалацией ниже себя не тянется (по замыслу — пол не должен переполнять собственное окно). Остаточный кейс, не дефект.
Checklist
Ревью — эскалация рекавери ai-chat (#520). Вердикт: needs-human
Весь PR корректен и хорошо покрыт (крюкс-счётчик, математика, back-compat, обычный чат — всё сверено + гейт зелёный: unit 156/156, int 3/3 на реальном pg, 3 мутации убиты). Открыт ОДИН вопрос — продуктовая развилка, которую я не должен решать сам: как рекавери ведёт себя при МАЛОМ настроенном окне модели. По умолчанию (окно не настроено) всё идеально; вопрос только про явно сконфигурированные окна < ~11 400 токенов.
Контекст (простыми словами)
Когда ai-chat отправляет модели слишком длинную историю, модель отвечает ошибкой «context overflow». Этот PR чинит баг #520: раньше рекавери один раз обрезала историю до 0.5× бюджета и, если и это не влезало, чат ЗАСТРЕВАЛ навсегда («кирпич»). Теперь при каждом ПОДРЯД overflow'е обрезка усиливается: бюджет × 0.5^k (k = число overflow'ов подряд), с нижним полом. Для дефолтного (ненастроенного) окна 100k всё работает: 100k→50k→25k→…→8k, пока не влезет, потом чистый ход сбрасывает k. Это правильно.
Главный вопрос
Нижний пол задан как
min(8000, настроенный_бюджет). Для НАСТРОЕННОГО окна модели меньше ~11 400 токенов (бюджет = 0.7×окно < 8000) пол становится равен самому бюджету → рекавери вообще перестаёт резать, при любом k. Два следствия:chatContextWindow=8000(реальная 8k-модель) → бюджет 5600; если 5600 токенов + системный промпт + схемы инструментов не влезают в 8k, старый код резал до 2800 и восстанавливался, новый застревает на 5600 навсегда.Автор ОСОЗНАННО закодил и покрыл тестом «никогда не резать ниже настроенного бюджета» (
history-budget.spec.ts:67«never inflates a small configured budget above itself»). Поэтому это не явный баг-с-очевидным-фиксом, а решение о контракте: настроенный админом бюджет — это ЖЁСТКИЙ пол или нет? Я не могу решить за продукт (нужно знать, поддерживает ли gitmost малоконтекстные модели).Варианты
Рекомендация: как минимум НЕ регрессировать — сделать пол настоящим АБСОЛЮТНЫМ минимумом, а не «= настроенному бюджету» (напр.
Math.max(scaled, Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(threshold*0.5)))), чтобы для базы<8000 сохранился хотя бы старый 0.5×-рез (спека «не РАЗДУВАТЬ малый бюджет» при этом остаётся выполнена — раздувание и так не происходит). Это ≈ Вариант B с полом ~половина-бюджета. Выбирай A, только если окна < ~11.4k явно вне scope #520 — тогда стоит задокументировать, чтоchatContextWindowдолжен быть ≥ ~11 400.Объективка зелёная, весь остальной PR верен — открыт только этот выбор. Loop остановлен до твоего решения.
Решение по эскалации (владелец): Вариант B — рекавери МОЖЕТ резать ниже явно настроенного бюджета при повторных overflow. Пол — абсолютный минимум по формуле ревьюера:
Math.max(scaled, Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(threshold * 0.5)))— для малых окон гарантирован как минимум прежний 0.5×-рез, эскалация 0.5^k продолжается ниже бюджета до пола.Обоснование: «чат не кирпичится на overflow» — инвариант реактивной ветки (спека эпика #497/итерация 5), а не best-effort; настроенное окно — утверждение о ёмкости модели, не обещание размера реплея; когда провайдер фактом 400-ки доказал несоответствие, реальность бьёт конфиг. Противоречия с решением по #510 (опция A) нет: там верхняя граница ШТАТНОГО пути (уважать конфиг, пока влезает), здесь — выживание при доказанном невлезании.
К исполнению вместе с B:
history-budget.spec.ts:67(«never inflates a small configured budget above itself» — само свойство «не раздувать» сохраняется, но кейс «жёсткий пол = бюджет» после B стал бы подгонкой).Реализована Опция B (решение владельца) — на ревью
Реализовал вариант B: рекавери может резать реплей-бюджет НИЖЕ настроенного при повторных overflow.
Формула пола (
history-budget.ts,resolveEffectiveReplayThreshold):Пол сменён со старого
min(REPLAY_MIN_FLOOR_TOKENS, thresholdTokens)(пиннился к бюджету) наmin(REPLAY_MIN_FLOOR_TOKENS, floor(0.5×budget)).REPLAY_MIN_FLOOR_TOKENSуже существовал = 8000, переиспользован (новую константу не вводил).thresholdздесь = производный реплей-бюджет (floor(0.7×окно)), не окно.Что реально чинится: для МАЛЫХ окон (budget ≤ 8000, т.е. окно ≤ ~11400 — ровно кейс из needs-human ревьюера) старый пол =
min(8000, 5600) = 5600 = бюджет→ эскалация упиралась В бюджет и НЕ резала ниже → кирпич #520. Новый пол = 2800 → расклинивает (гарантирован как минимум прежний 0.5×-рез). Для БОЛЬШИХ окон (budget ≥ 16000) поведение идентично старому (пол так и 8000).Наблюдаемость:
replayBelowConfiguredBudget— warn-лог (настроенный бюджет + фактический + k) при резе ниже бюджета, и флаг в metadata хода (штампуется только когда реально ниже). Сброс k на чистом ходу сохранён.Тесты: перепинен
history-budget.spec.ts:67(инвариант «не раздувает выше себя» жив; убрано допущение floor==budget), + escalation-below-budget (окно 8000→бюджет 5600→k≥1 режет до пола 2800; чистый ход сбрасывает k) + large-window спуск до 8000 + observability. 151/151. Мутации: пол→budget краснит below-budget тесты (large-window зелёный — подтверждает, что дельта бьёт по малым окнам); снятие metadata-штампа краснит observability-тест. Отребейзено на develop (61 коммит, конфликтов нет).Два INFO на подтверждение (@vvzvlad):
min(8000, 2800) = 2800, так что спуск ОДНОШАГОВЫЙ: k=1→2800, k≥2 остаётся 2800 (пол достигнут). Многошаговый спуск («k=2→1400…») из твоего описания достижим только для окон > ~22857 (где0.5×budget > 8000). Я следовал ФОРМУЛЕ (она авторитетна), числа примера чуть расходятся с ней — это ожидаемо, не баг.REPLAY_MIN_FLOOR_TOKENS = 8000— подтверди, что это желаемый абсолютный пол. Менять его значение сам не стал (сдвинет пол сходимости для всех больших окон).внутренний цикл: 1 проход внутреннего ревью — APPROVE,
threshold-связывание корректно, недостижимость нарратива = следствие формулы, наблюдаемость/сброс целы, мутации хирургичны.Новый head: rebased.
6f3237bc41to688cb54f26Ре-ревью — эскалирующая рекавери, Вариант B (#520). Вердикт: approved ✅
Реализовано решение владельца (Вариант B) — рекавери МОЖЕТ резать реплей-бюджет ниже настроенного при повторных overflow. Проверил против кода + мутация.
📋 Проверка Option B
Формула (
history-budget.ts:resolveEffectiveReplayThreshold) — ровно как решено:Пол =
min(8000, floor(threshold·0.5))— абсолютный минимум, а не «=настроенному бюджету».Поведение (проверил):
chatContextWindow=8000): пол = min(8000, 2800) = 2800 → эффективный k0=5600 → k1=2800 (было запинено на 5600 = кирпич). Un-brick для малых окон достигнут.Тесты: новый «un-bricks: escalation drops below a small real window the fixed 0.5× never could» (
history-budget.spec:50); ранее «enshrining»-спека «never inflates a small budget» переписана в «may cut below it, #520 Option B» (:70). Мутация-локк: вернул пол к старомуmin(FLOOR, threshold)(бриктующая версия) → 2 теста (un-brick + may-cut) КРАСНЕЮТ. Не вакуумно.Гейт: unit 160/160 (было 156 + Option-B тесты), int (write-volume) 3/3 на Docker pg (6G heap). Own delta vs свежий develop = 6 файлов (recovery-скоуп; PR отребейзен на develop, ядро счётчика/математики было LGTM на эскалации). Дерево после мутаций чисто.
Эскалация закрыта: я вынес развилку → ты решил Вариант B → кодер реализовал по твоей/моей формуле → проверил + мутация-локк → approve. Готово к мержу.