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