hermes-android/agy-work/TASK-2026-08-24-09-performance-and-attribution.md

101 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 + доработка)`, `## Замеры до/после`, `## Вердикт оркестратора`.