12 KiB
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
- Проекция добавлена, но
sessionsпо-прежнему вызываетgetSessionWithDetailsв другой ветке. Проверить поиском все вызовы. - Превью последнего сообщения считается подзапросом на строку — N+1 вернулся в другой форме. Проверить фактическое число запросов, а не форму кода.
WhileSubscribedприменён к потоку, но сама картаsessionMessagesStateпродолжает держатьMutableStateFlowс историей — освобождается подписка, а не память.- Карта мьютексов из задания 02 не почищена — утечка осталась, просто в другом месте.
- Карта
toolId → messageIdне чистится при завершении сообщения — третья утечка. - Строгое соответствие
messageIdвведено, но событие с пустым id теперь молча теряет инструмент. Проверить взаимодействие с валидацией задания 01: событие без id туда вообще не должно доходить. - Автопрокрутка «починена» удалением — список перестал следовать за стримом. Это регресс, а не фикс.
- Замеры «после» сняты на другом наборе данных, чем «до». Требование: один и тот же сценарий и объём.
- В отчёте
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
./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 + доработка), ## Замеры до/после, ## Вердикт оркестратора.