# Task 09: Производительность и правильность атрибуции (hermes-android) **Repo:** `ochenstarik-ui/hermes-android` **Assigned to:** Antigravity (режим оркестратора, два кодера) **Priority:** MEDIUM (растущая утечка, просадка списка, неверная привязка инструментов) **Date:** 2026-08-24 **Base SHA:** результат задания 08 — указать фактический SHA при выдаче **Зависимость:** задания 02 и 03 приняты — атомарные обновления таймлайна и настоящие ViewModel уже на месте. ## Роли и протокол | Роль | Модель | Что делает | |---|---|---| | Оркестратор | Antigravity | Разбивает работу, маршрутизирует, принимает результат | | Кодер 1 | Gemini Flash 3.7 high | Реализация §Scope целиком | | Кодер 2 | Gemini Pro high | Независимая проверка работы кодера 1 и доработка | **Раунд 1 (кодер 1).** Тесты и замеры из §Required tests, фиксация исходных значений на base SHA, затем код по пунктам §Scope. Нерешённое — в `OPEN QUESTIONS`. **Раунд 2 (кодер 2).** Независимо повторяет замеры своим прогоном, проходит §Anti-checklist с явными отметками, доводит минимальным дифом, findings по шкале. **Раунд 3 (оркестратор).** Приёмка при наличии парных замеров «до/после» от обоих кодеров, а не утверждений «стало быстрее». ## Проблема **1. Список сессий делает N+1 запросов (`DATA-07`).** `UnifiedSessionRepository.kt:32-39` — в `map` по списку сессий для каждой вызывается `getSessionWithDetails(entity.id)`, то есть полная выборка со всеми сообщениями и привязками. И так на каждую эмиссию потока, а поток дёргается при любой вставке сообщения. На сотне сессий с историей это заметная просадка главного экрана и лишняя работа во время стрима. **2. Кэши в памяти растут неограниченно (`DATA-09`).** `UnifiedSessionRepository.kt:49-54` — `sessionMessagesState`, `hostExecutingState`, `sessionExecutingState` заполняются через `computeIfAbsent` и очищаются только при удалении сессии. Каждая открытая за сессию работы приложения переписка остаётся в памяти вместе с полной историей. К этому же классу относится карта мьютексов, введённая заданием 02 (`DATA-06`), если её чистка не была сделана. **3. Инструменты и «мышление» цепляются не к тому сообщению (`DATA-10`).** `UnifiedSessionRepository.kt:713-731` — `attachToolToSessionMessage` ищет `lastOrNull { role == ASSISTANT && hostId == host }`. Обработчики `thinking.delta` и `reasoning.delta` (`:585,:597`) используют условие `(it.id == event.messageId || it.role == ASSISTANT)`: из-за `||` дельта уедет в произвольное последнее сообщение ассистента, даже когда `message_id` известен и не совпадает. При двух хостах, пишущих в один таймлайн, карточки инструментов и трассы рассуждений оказываются под чужими сообщениями — то есть ломается ровно то, что README называет host attribution. **4. Автопрокрутка перезапускается на каждый символ (`UI-07`).** `ChatScreen.kt:90-95` — ключ эффекта включает `messages.lastOrNull()?.content?.length`, во время стрима эффект перезапускается десятки раз в секунду, каждый раз отменяя предыдущую анимацию. Индекс `messages.size + approvals.size` на единицу больше последнего допустимого. ## Scope **1. Лёгкий список сессий (`DATA-07`).** Отдавать в список проекцию: идентификатор, заголовок, активный хост, отметка времени, превью последнего сообщения, счётчик. Одним запросом, без выборки истории. Полные детали грузить только на экране чата. Проверить, что при этом не сломался порядок из задания 02 (`updatedAt`) и что поток не переэмитит на каждый фрагмент стрима. **2. Ограничение кэшей (`DATA-09`).** Освобождать состояние сессии, когда на него никто не подписан: `WhileSubscribed` с таймаутом либо явная очистка при уходе с экрана чата. Карты `hostExecutingState`, `sessionExecutingState` и мьютексов чистить тем же путём. Требование: после открытия и закрытия 50 сессий подряд объём удерживаемых сообщений возвращается к базовому. **3. Точная атрибуция (`DATA-10`).** Вести карту `toolId → messageId` и использовать её вместо поиска «последнего сообщения хоста». Условие с `||` в обработчиках `thinking`/`reasoning` заменить на строгое соответствие `messageId`; фолбэк на последнее сообщение — только когда идентификатора нет вовсе, и с явной пометкой в коде, почему это допустимо. Проверить сценарий двух хостов, стримящих одновременно в одну сессию. **4. Прокрутка (`UI-07`).** Скроллить через `snapshotFlow` с `conflate()`; целевой индекс — последний допустимый; во время активного стрима не анимировать, а держать позицию. Учесть, что пользователь мог прокрутить вверх намеренно: автопрокрутка не должна возвращать его вниз, пока он читает историю. ## Do not change - Схему БД сверх добавления нужных проекции индексов; изменение схемы версионируется по правилам задания 02. - Транспорт и порядок событий — задания 01 и 08. - Логику подтверждений — задание 03. - Оформление экранов — задание 10. ## Anti-checklist 1. Проекция добавлена, но `sessions` по-прежнему вызывает `getSessionWithDetails` в другой ветке. Проверить поиском все вызовы. 2. Превью последнего сообщения считается подзапросом на строку — N+1 вернулся в другой форме. Проверить фактическое число запросов, а не форму кода. 3. `WhileSubscribed` применён к потоку, но сама карта `sessionMessagesState` продолжает держать `MutableStateFlow` с историей — освобождается подписка, а не память. 4. Карта мьютексов из задания 02 не почищена — утечка осталась, просто в другом месте. 5. Карта `toolId → messageId` не чистится при завершении сообщения — третья утечка. 6. Строгое соответствие `messageId` введено, но событие с пустым id теперь молча теряет инструмент. Проверить взаимодействие с валидацией задания 01: событие без id туда вообще не должно доходить. 7. Автопрокрутка «починена» удалением — список перестал следовать за стримом. Это регресс, а не фикс. 8. Замеры «после» сняты на другом наборе данных, чем «до». Требование: один и тот же сценарий и объём. 9. В отчёте `green` для незапущенной команды (`AGENTS.md §3`). ## Definition of Done - Открытие списка из 100 сессий с историей по 200 сообщений выполняет фиксированное число запросов, не зависящее от числа сессий, — подтверждено счётчиком запросов, а не ощущением. - Во время стрима список сессий не перевыбирает историю. - После открытия и закрытия 50 сессий удерживаемый объём возвращается к базовому. - Инструмент, начатый на хосте A, отображается под сообщением хоста A даже когда хост B стримит одновременно. - Трасса рассуждений с известным `message_id` не попадает в чужое сообщение. - Прокрутка следует за стримом плавно и не перебивает намеренную прокрутку пользователя вверх. - Все существующие тесты зелёные, ни один не удалён и не ослаблен. ## Required tests `core/repository/SessionListQueryCountTest.kt` — число запросов при 100 сессиях фиксировано. Обязан падать на base SHA. `core/repository/CacheEvictionTest.kt` — после отписки состояние сессии освобождается; карты не растут. `core/repository/ToolAttributionTest.kt` — два хоста стримят одновременно, инструменты и рассуждения под своими сообщениями. Обязан падать на base SHA. `feature/chat/AutoScrollTest.kt` (androidTest) — прокрутка следует за стримом; ручная прокрутка вверх не перебивается. Замеры, обязательные в отчёте обоих кодеров, на одном сценарии до и после: - число SQL-запросов при открытии списка; - удерживаемая память после цикла из 50 сессий; - частота перезапуска эффекта прокрутки за 10 секунд стрима. ## Required verification ```text ./gradlew --no-daemon testDebugUnitTest ./gradlew --no-daemon connectedDebugAndroidTest ./gradlew --no-daemon lint ./gradlew --no-daemon assembleDebug ``` ## Result `agents/antigravity/done/TASK-2026-08-24-09-performance-and-attribution.md` — разделы `## Кодер 1`, `## Кодер 2 (review + доработка)`, `## Замеры до/после`, `## Вердикт оркестратора`.