Skip to content

Latest commit

 

History

History
451 lines (371 loc) · 43.8 KB

ROADMAP.md

File metadata and controls

451 lines (371 loc) · 43.8 KB

Дорожная карта: opencode-mobile → продукт для чужих телефонов

Три трека по честным хвостам. Каждый — шаги, файлы, критерий готовности.

Порядок внедрения: Трек 1 (release) → Трек 2 (сеть) → Трек 3 (turbo). Release-контур блокирует «дать человеку», сеть блокирует «мобильный opencode», турбо — перф.

Отдельно, вне этих трёх треков: Трек 4 — миграция на OpenCode 2, план в docs/MIGRATION-V2.md. Приложение пакует бинарь serve внутрь APK. Этапы 0–4 закрыты: в APK уже opencode 2.0.27, HTTP-слой перенесён на /api/* (/session/status удалён — статус синтезируется из псевдо-сообщения type:"idle"), вопросы и разрешения остались на v1-ветке через ApiPaths.active.legacy. Открытые этапы — 5–7. Риск WebView снят: SPA v2 грузится и работает.


Трек 1 — Release-контур (самый важный, делаем первым)

Боль: debug-APK 392MB, пакет .debug, isMinifyEnabled=false.

1. Release-минифицирование с .so без поломки

  • Включить isMinifyEnabled=true + shrinkResources=true в release блоке (app/build.gradle.kts, строчки ~20-26).
  • Критично: libopencode.so и whisper .so лежат в jniLibs — R8 их не трогает (только dex), но проверить, что packaging { jniLibs.useLegacyPackaging } не слетел (execve из nativeLibraryDir).
  • Добавить keep-правила для рефлексии (opencode serve пути) в proguard-rules.pro.
  • Ожидание: material-icons-extended (2000+ иконок) R8 выкинет неиспользуемые → экономия ~20-30MB dex.

2. Честный размер модели

  • ✅ base вынесена из APK в lazy-скачивание (как turbo). APK: 374 → 274 (R8) → 147 MB.
  • Реальный предел — ~147MB, т.к. libopencode.so (184 MB) — это сам opencode, вынести нельзя. Дальнейшие варианты:
    • App Bundle / on-demand (Play требует AAB) — ещё ужимает загрузку.
    • Опционально: убрать libbun-musl.so (70MB) если opencode может работать без bun-браузинга (рискованно, требует проверки запуска serve без bun).

3. Release-сборка и подпись

  • applicationIdSuffix убрать из release (сейчас только debug имеет .debug — ок, release будет чистый org.opencode.mobile).
  • Создать signing config (upload key), беймпинг versionCode/versionName (0.1.x).
  • Собрать assembleRelease, прогнать smoke-тест на устройстве: запуск, чат, STT.

Готово когда: assembleRelease даёт APK/AAB ~100MB (или lazy ~70MB), пакет чистый, всё работает под smoke-тестами на телефоне.


Трек 2 — Сеть без ручного прокси

Боль: на Android нет /etc/resolv.conf, musl шлёт на 127.0.0.1:53, внешние сервисы не ходят без прокси.

✅ Реализовано. Встроенный Ipv4Proxy (CONNECT-туннель на 127.0.0.1:3128, IPv4-first через InetAddress/Netd) + HTTPS_PROXY/HTTP_PROXY/NO_PROXY в OpencodeRuntime.startServe. Подтверждено на устройстве: туннели к mcp.context7.com, mcp.grep.app, registry.npmjs.org открываются автоматически.

Остатки (опционально):

  • Прогнать явный webfetch/git из чата на реальном телефоне как регресс-тест.

Готово: внешние вызовы opencode ходят в сеть без ручного прокси-конфига.


Трек 3 — STT turbo на CPU

Боль: большие модели душатся до 1-5% (ColorOS/OPPO), turbo медленный.

6. ncnn-int8 для энкодера

  • ✅ Внедрено (следовали tools/ncnn-int8-plan.md; KV-cache уже был). int8-encoder (block-quant) грузится сразу в сеть (при неудаче — откат fp32), замерено: int8-CPU 8.82 s vs fp32-CPU 17.9 s (~2×), int8-Vulkan 6.45 s (эксперимент, в проде выключен — см. docs/EXPERIMENTS-STT-LATENCY.md §2).
  • Исполнитель: ModelDownloader качает int8-варианты турбо; ncnn_jni.cpp::load() выбирает их автоматически.

7. Многопоточность / foreground / потоковый пайплайн

  • WhisperTranscribeService уже держит приоритет (foreground).
  • ✅ Потоковый пайплайн/чанкинг внедрён (ЭКСП-5, сентябрь 2026): SpeechSegmenter (VAD по RMS) + ChunkedTranscriber — длинная речь режется на сегменты, каждый распознаётся отдельно: первые слова видны на 1–2 s, хвост >30 с не теряется, галлюцинаций на тишине меньше.
  • ✅ Бенч turbo int8 на реальном OPPO прогнан (24.09.2026): int8 6.8–8.2 s vs fp32 14.5–15.2 s (~2.1×); CSV: docs/stt-bench-2026-09-24.csv.
  • ✅ WER-бенч работает (26.10.2026). tools/stt-accuracy.ps1 гоняет клипы на устройстве (SttBenchReceiver → logcat), эталон — C:\Projects\MyFLOW\data\eval_wispr (phrases.jsonl + 624 wav). Первый прогон на 7 клипах (1,3–10,8 с): whisper small — средний WER 10,47 %, corpus WER 13,85 % (9 правок / 65 слов); parakeet parakeet-tdt-0.6b-v3 — corpus 9,52 % (6 / 63), но два односложных клипа не дали результата вовсе, поэтому сравнение пока нечестное.
  • Метрика исправлена: средний WER по клипам — шум на наборе с односложными фразами (одна буква в слове из одного слова даёт 100 %). Скрипт теперь печатает и corpus WER (правки на слово по всему набору), и число клипов ≤2 слов.
  • Осталось: прогон по всему корпусу (624 клипа) и разбор, почему parakeet молчит на односложных клипах; тюнинг setThreads на других устройствах.
  • ✅ VAD-пороги измерены, а не угаданы (26.09.2026): офлайн-свип SpeechSegmenterBenchTest на реальных bench-wav, 6 порогов × 5 SNR = 30 замеров. Recall речи 90.7% на всём диапазоне 0.010–0.030; 0.015 и 0.025 дают идентичный результат во всех 30 замерах, то есть предложенный сдвиг порога — no-op. MIN_RMS остаётся 0.015. Отчёт: app/build/reports/vad-bench.txt.
  • ✅ Прошлый вывод «WER посчитать нельзя» снят (26.10.2026). Он опирался на то, что эталона нет в репозитории: корпус лежит рядом, в C:\Projects\MyFLOW\data\eval_wispr, и его достаточно. Считать WER по выводу модели по-прежнему нельзя (на jfk.wav int8 отдаёт текст, не являющийся цитатой) — но с внешним эталоном это и не нужно.

Готово когда: потоковый пайплайн с первым результатом 1–2 s + WER-прогон — ✅ пайплайн внедрён (1–2 s первые слова); пороги VAD измерены; WER-бенч работает и дал первые числа на 7 клипах. Полный прогон по корпусу — отдельная задача, не блокер.


Дополнительный трек — безопасность и память (сентябрь 2026)

✅ Basic Auth (PR1)

  • serve за паролем: ServerAuth (генерация, зашифрованные prefs, заголовки).
  • Единый клиент LocalOpenCodeClient (auth в одном месте).
  • Готово: все запросы к 4096 с Authorization; без — 401. Проверено e2e.

✅ Локальная память MCP (PR2)

  • memory.js на 127.0.0.1:4199, регистрация в serve через ensureMcpConfig (фикс «0 MCP» — пустой opencode.jsonc), протокольная проверка в RuntimeValidation (GET /mcp → 2xx).
  • ✅ Инструмент «забыть» из UI (26.10.2026): секция «Локальная память» в диагностике — счётчики, последние 20 записей и кнопка «Забыть» на каждой с подтверждением. Вызовы идут из приложения напрямую в 127.0.0.1:4199/mcp (MemoryTools + MemoryWire), без модели в цепочке: иначе удаление зависело бы от того, согласится ли агент вызвать инструмент. Проверено на устройстве сквозным прогоном: тестовая запись mem:rg9z6ilphsbmvohwvi3 создана агентом, удалена кнопкой, в базе не осталось ни строки, ни terms, ни рёбер graph; «Всего» 54 → 53.

День 2 — регресс-тесты и два расследования (01.10.2026)

Хвост после сессии «холодный запуск Яндекс.Музыки» (69ae486, 994053b). Проверено на устройстве: BAL обойдён через ручной SYSTEM_ALERT_WINDOW grant, холодный старт при убитой ЯМ и фоне — 53 с и 81 с без BAL_BLOCK.

Что выяснено при инвентаризации тестов

  • ⛔ AppInstallBridgeTest.kt не существует вообще. На все роуты моста, разбор JSON и сериализацию тестов нет — то есть на весь код, который эта сессия добавила и который ломался дважды.
  • ⛔ В ynison.js нет ни одного экспорта. Все 48 существующих JS-кейсов (announce 22, goaway 7, search 15, session 4) работают приёмом «вырезать блок исходника по маркерам» (loadBlock + BLOCK_START/BLOCK_END). bridge(), opSessionStatus, opSessionControl покрыты нулём.
  • ✅ play_pause уже покрыт: в MediaControlContracts ALIASES содержит и play_pause, и play-pause, и toggle, а MediaControlRequestValidatorTest сверяет список wire-имён. Пункт «добавить тест на play_pause» вычеркнут.
  • ✅ McpMusicConfigTest теперь держит и TIMEOUT: тест сверяет "timeout": 180000 в записи music и отсутствие timeout у памяти и телефона (переименование в timeoutMs opencode молча игнорирует и возвращается к дефолту — ловится здесь).

✅ P0 — закрыть регрессы тестами (25.10.2026)

app/src/test/java/.../installer/AppInstallBridgeTest.kt — 10 тестов. Чтобы эти поля вообще можно было проверить, JSON-формы вынесены из приватных обработчиков моста в installer/AppInstallWire.kt (тело запроса, код ошибки, ответ на запуск) и media/MediaWire.kt (формы медиа-ответов).

  1. ✅ jsonObject() на пустом теле → {} (раньше был 400, GET без body падал)
  2. ✅ не-объектное тело → IllegalArgumentException → 400, не 500
  3. ✅ JSONException (нет обязательного поля) → 400: отдельная ветка, потому что он не наследник IllegalArgumentException. Тест заодно фиксирует это родство — если оно изменится, ветка перестанет быть нужной молча
  4. ✅ ok в /v1/apps/launch — литерал true, не зависит от background_launch_allowed
  5. ✅ toJson отдаёт background_launch_allowed и не отдаёт running
  6. ✅ media.verified вложен в media, в корне его нет
  7. ✅ fallback-тексты ошибок, когда у исключения нет message
  8. ✅ bridge() не шлёт body на GET/HEAD — уже было в ynison-bridge.test.mjs
  9. ✅ clearTimeout на всех путях, компактный opSessionStatus с фильтром по controlable — тоже ynison-bridge.test.mjs
  10. ✅ сверка enum session_control в ynison.js с MediaCommand — SessionControlEnumContractTest.kt (читает исходник JS)

Проверено: build.ps1 -Task verify — 775 тестов, 0 падений, ktlint и detekt чистые; jstest зелёный. Каждый тест проверен откатом фикса: с возвращёнными регрессами краснеют ровно шесть тестов класса, после отката — зелено.

P1 — расследования, не правки

Effective-конфиг MCP — источник найден (25.10.2026), замеры ждут телефон.

Прежняя формулировка («/data/data/.../opencode.jsonc не содержит ynison, источник не найден») описывала не тот файл. Конфиг целиком пишет приложение:

Путь Кто пишет Роль
<filesDir>/opencode-config/opencode/opencode.jsonc OpencodeRuntime.ensureMcpConfig действующий: serve стартует с XDG_CONFIG_HOME=<filesDir>/opencode-config (startServe)
<base>/config-mirror/opencode.jsonc mirrorConfig после успешного ensureMcpConfig эталон для чтения по USB, односторонняя копия
<base>/opencode/opencode.jsonc старый путь до переезда на ext4 остаётся на диске после разовой миграции (migrateConfigFrom) и больше не обновляется — вероятно, именно его и смотрели

Порядок записи: ensureMcpConfig → ensureMemorySection (создаёт управляемую mcp-секцию) → syncMusicEntry (добавляет/убирает ynison по наличию runtimeAccessToken()) → syncMoltbookEntry → syncModelEntry → mirrorConfig. Отказ любого шага — отказ файла целиком (fail-closed), зеркало не пишется.

YNISON_MCP_TIMEOUT_MS = 180000 подтверждён как действующий: в схеме opencode 2.0.27 (бинарь @opencode/cli-linux-arm64-musl@2.0.27, объект McpRemoteConfig) есть поле timeout — «Timeout in ms for MCP server requests, Defaults to 5000»; в документации opencode то же поле у remote-серверов. То есть приложение пишет не мёртвый ключ. Имя и значение закреплены тестом McpMusicConfigTest.«music получает timeout 180000 мс…»; проверено откатом: timeout → timeoutMs краснит ровно этот тест.

Замер выполнен 25.10.2026 — 180 с не участвуют, вызов умирает на go-away 503.

music_play_query при убитой Яндекс.Музыке: 18,5 с, ошибка no player_state after handshake, приложение так и не стартует (pidof пуст) — то есть ensurePhone не доходит, клиентский таймаут и холодный старт ни при чём. Причина в трассе /storage/emulated/0/Documents/OpencodeTerminal/ynison-trace.log: сервер отвечает {"error":{"details":{"ynison-error-code":"500140000","ynison-go-away-for-seconds":"3600"}, "http_code":503,"message":"Ynison service is temporary unavailable."}} — вложенная форма, которую goAwayError не узнавал (искал код в корне). Кадр оставался обычным кадром состояния, и вызов падал позже на «нет player_state».

Исправлено: код и интервал читаются из обеих форм, проверка бана стоит и после handshake, интервал запоминается на процесс (пока бан идёт, сокет не открывается). ynison-goaway.test.mjs — 14 тестов, оба отката краснят. Остаётся проверить на устройстве после установки: сообщение должно называть 503 и остаток интервала, а второй вызов подряд — не открывать соединение.

Проверено на устройстве 25.10.2026 — оба утверждения держатся. Первый вызов: 3,7 с, текст называет 500140000 и 3600s. Второй вызов подряд: 0,004 с, новых соединений в трассе нет (сразу отказ по дедлайну). То есть вызов больше не висит 18,5 с и не открывает сокет внутри бана.

Второй бан пришёл 22:54, и это отдельная проблема — дедлайн жил только в памяти. Разбор трассы показал две вещи. Первое: у строк трассы не было времени, поэтому по ней нельзя сказать, когда случился бан и какой вызов его получил. Второе: музыкальный MCP поднялся в 21:57, а бан от 21:44 к этому моменту был забыт — первый вызов после рестарта открыл сокет и собрал свежие 3600 с (дедлайн до ~23:59). Плюс handoffUntil продолжал делать три попытки записи внутри бана, давая три одинаковые строки.

Исправлено 25.10.2026: trace() подписывает строки ISO-временем, дедлайн бана кладётся рядом с кэшем состояния (ynison-goaway.json) и читается при старте, handoffUntil прерывает повторы на permanent-ошибке. ynison-goaway.test.mjs — 21 тест, семь откатов краснят свои тесты. Что осталось непонятным: кто именно вызвал музыку в 22:54 (в тот момент человек с телефоном не работал). Со временем в трассе следующий такой случай будет виден по часам; гипотезы — фоновый агент moltbook и music_status из панели диагностики.

Запуск музыки при этом работает: /v1/apps/launch для ru.yandex.music отвечает за 0,45 с с background_launch_allowed: true (accessibility включён). Значит недоступен именно Ynison, а не мост и не телефон.

Проверка выполнена 25.10.2026 — блока не было, причина найдена и исправлена. Токен живой (files/opencode.log: ynison MCP http on 127.0.0.1:4200, порт слушает), а конфиг не переписывался с 20.10. Предупреждения Managed mcp block has unexpected formatting в logcat не было вовсе — приложение пишет свои Log.* мимо logcat, поэтому искать надо было не там. Виноват hasManagedMcpBlock: он узнавал секцию mcp целиком (mcpBlock()), а с подключённым moltbook после телефона стоит запись moltbook, а не }. Отсюда ensureMemorySection → false → memoryReady && syncMusicEntry(...) не выполнялся вовсе, и music не регистрировалась никогда. planMusicEntry страдал тем же сравнением и уходил в Refuse. Обе проверки переведены на уровень записей (McpMusicConfigTest, 20 тестов, проверено откатом). Проверено на устройстве: после установки записи music в конфиге нет причин не быть — "timeout": 180000 между mobile_tools и moltbook, GET /api/mcp отдаёт четыре сервера connected.

26.10.2026 — «кто вызвал музыку в 22:54» выяснено: никто не вызывал.

Разбор opencode.log показал 1632 строки mcp transport error server=music между 21:35 и 04:28 по устройству — по одной каждые ~13 с, и ноль таких строк у memory, mobile_tools и moltbook. Причина в ynison.js: GET-ветка отдавала SSE-поток, который писал один кадр endpoint и дальше молчал, а Bun закрывает простаивающий ответ через 10 с по умолчанию. opencode видел это как отказ транспорта (а не как ответ инструмента) и переподключался; каждый повтор медленного вызова (handshake, тикет, state-сокет) открывал новую сессию Ynison, а тот на каждый заход отвечает stay away 3600s — то есть бан продлевался сам, пробой за пробой, и не мог истечь. В memory.js это лечение было с самого начала (srv.timeout(req, 0) + keepalive-кадр), в ynison.js — нет.

Исправлено 26.10.2026: server.timeout(req, 0) в обеих ветках (медленный POST тоже рвался на 10 с) и keepalive каждые 15 с с очисткой по cancel/abort. ynison-sse.test.mjs — 6 тестов, оба отката (снятый timeout и keepalive 30 с) краснят свои. Проверено на устройстве: до правки ~1 обрыв в 13 с, после установки — 0 обрывов за 3,5 минуты, mcp connected server=music на старте. Дальше бан должен истечь сам; сквозной прогон музыки — после 04:58 (последнее продление было в 03:58).

Зацепка из прошлых сессий: RuntimeManager уже умеет fail-closed на конфиге — ответ может лежать там. И отдельно: в mem:opencode-mobile-installer-final-20260925 замечено, что 15-минутный опрос установщика может превысить таймаут MCP-клиента. Два независимых наблюдения одного класса — возможно, проблема не в ynison. (Замером 25.10 это не подтвердилось: вызов рвётся за 18,5 с по go-away 503.)

Seed/mirror-цикл ensurePhone(). Ynison может ответить зеркалом собственных announce-вызовов, и клиент примет это за live-state → риск потери состояния живой сессии. Воспроизвести tools/ym-exp.ps1 -Variant nowrite / -Variant nohandback, найти точку подмены, потом минимальный фикс. Правка без воспроизведения — гадание, поэтому сначала эксперимент.

26.10.2026 — найдена настоящая причина «state open timeout»: сокет состояния шёл не туда.

Вызов музыки висел 97,7 с и падал state open timeout, buffered 0B. Диагностика (ярлык state/redirect в каждой строке трассы, статусная строка ответа, отказ по закрытию соединения вместо 10-секундного таймера) показала: редирект-сокет работает целиком — 101, payload, тикет, закрытие; а state-сокет получает 0 байт за 10 с.

Причина: state-сервис отвечает только на адресе шарда. Приложение резолвит ynison.music.yandex.ru (фронтенд) и передаёт этот IP в ynison.js (RESOLVED_YNISON_IP), а Host/SNI у state-сокета — шардовый k3t3i6….ynison.music.yandex.ru. Фронтенд принимает TLS и запрос и не отвечает вообще. Замер с PC при одинаковых Host, пути и токене: фронтенд 77.88.21.101 — 0 байт, таймаут 6 с; шард 77.88.48.156 — 101 Switching Protocols за 0,16 с. DNS: фронтенд → 77.88.21.101, шард → 77.88.48.156.

Заодно снят миф, на котором стоял resolveIpv4 — «musl-Bun не умеет DNS на Android»: на устройстве dns.lookup("k3t3i6….ynison.music.yandex.ru") → 77.88.48.156 (тот же bun запущен под run-as). Поэтому резолв шарда делается в JS, а не приходит снаружи.

Исправлено: resolveStateHost() резолвит шард на месте и отдаёт его адрес в state-сокет; при отказе резолва — именованная ошибка вместо тишины. ws.onclose до апгрейда теперь отклоняет промис (state socket closed before upgrade): раньше любой ранний разрыв выглядел как таймаут.

Проверено на устройстве 26.10.2026: state host: … -> 77.88.48.156 → state connect → state head: HTTP/1.1 101 Switching Protocols; вызов вернулся за 7,86 с вместо 97,7 с. Ответ сервера при этом 500140000 / «stay away 3600s» — Ynison сам просит подождать (устройство насобирало сессий за отладку), второй вызов подряд отказывает за 0,07 с без нового соединения. Сквозной прогон музыки — после интервала.

Пробный вызов продлевает бан на новый час. Через час после первого бана (дедлайн 02:56:58) один music_play_query вернулся за 7,2 с с текстом «token is valid — wait out the interval» и перезапустил блокировку до 03:57:20. Значит «проверить, пустило ли» нельзя: каждый замер стоит ещё час, и так можно не дождаться никогда. Сквозной прогон требует часа полной тишины — ни music_play_query, ни music_status.

26.10.2026, 05:54–06:12 — чем блокировка НЕ является (замерено, а не предположено).

Четыре попытки подряд, каждая с новым дедлайном, и один контрольный опыт:

время что зашли итог
05:54 id телефона (…LB5PAi) 503 500140000, +3600 с
05:58 новый id (локальный пробник на ПК) сессия открылась (уперлись в пустую очередь)
06:00 тот же новый id, второй заход 503 500140000, +3600 с
06:12 ещё один новый id, первый заход 503 500140000, +3600 с
— Яндекс.Музыка на телефоне играет, state=PLAYING, позиция растёт

Выводы, которые из этого следуют:

  1. Это не бан по device id. Новый id, который ни разу не подключался, получил 503 с первого захода. Прежняя формулировка в коде («ban counted per device id») была неверна.
  2. Это не проблема аккаунта, токена и не авария сервиса: приложение на том же телефоне в это же время играет через тот же аккаунт.
  3. Значит троттлится токен OAuth, которым ходит наш клиент, и любой заход во время троттлинга снова взводит час. Смена id — не выход.
  4. Что троттлинг вызвало — уже известно: 1632 обрыва MCP-транспорта между 21:35 и 04:28, каждый обрыв = новый handshake (см. выше). Лечение транспорта сделано, но счётчик Yandex сам не обнулился.

Сделано, чтобы это не повторялось (26.10.2026).

  • Одна сессия на все вызовы (SESSION_TTL_MS = 10 мин). Раньше каждый тул-вызов открывал свой сокет, а music_play_query — ещё и второй на верификацию: один запрос пользователя стоил 2–3 захода. Теперь заход один, дальше сокет переиспользуется, кадры копятся в живом массиве (хвост 120 кадров, последнее состояние не теряется), а readState читает последний кадр, а не первый — иначе на живой сессии он показывал бы трек, который играл при её открытии.
  • Параллельные вызовы не дублируют заход (openingSession): два тула в одном батче ждут один диал, а не открывают два.
  • Бэкофф с удвоением (GO_AWAY_MAX_MS = 12 ч): час, два, четыре, восемь, двенадцать. Счётчик attempts лежит в ynison-goaway.json рядом с дедлайном, поэтому рестарт процесса не обнуляет его и не возвращает часовой цикл. Успешная сессия счётчик сбрасывает.
  • Тесты: ynison-session.test.mjs (5) переписан под новый контракт «сокет остаётся жить», новый ynison-session-cache.test.mjs (11) — переиспользование, TTL, мёртвый сокет, бан на живой сессии, удвоение, потолок, двойной кадр одного захода, сброс после успеха. -Task jstest зелёный.

Состояние прямо сейчас: троттлинг активен. Файл ynison-goaway.json на устройстве вручную отодвинут на 6 часов (до ~12:08), чтобы старый код на телефоне не взвёл его снова. Проверка «Playback >100 с» возможна не раньше этого срока — одним вызовом, без предварительных «а пустило ли».

Переустановка (26.10.2026, 06:28): собран и поставлен adb install -r -d APK 6fc15add — на телефоне теперь новый ynison.js (проверено по run-as … grep throwIfBanned files/mem/ynison.js: 2 вхождения; ensureAssetScript перезаписал копию из assets при старте). Музыкальный сервер поднялся (ynison MCP http on 127.0.0.1:4200, порт отвечает), dial'а не было — новый код прочитал {"until":1792998517901,"attempts":3} и не тронул троттлинг.

Гранты после переустановки (замер): appops get … SYSTEM_ALERT_WINDOW — allow (time=+1d22h20m ago, то есть adb install -r его не сбросил). А accessibility-сервиса в settings get secure enabled_accessibility_services нет — там только Microsoft AppManager, наш MediaUiAutomationService отсутствует; включать заново руками.

Что при этом работает уже сейчас: управление воспроизведением через MediaSession (session_control) — проверено на телефоне 26.10.2026: pause → paused, not playing, play → playing, трек тот же, позиция та же. То есть пауза/плей/следующий/предыдущий доступны без Ynison; недоступен только выбор произвольного трека — он идёт через Ynison.

P2 — если останется время

  • ⛔ Playback >100 с после холодного старта не проверен: только короткие прогоны. Живой вопрос — деградация позиции, потеря сессии, обрыв MCP-вызова. Блокер — троттлинг токена (26.10.2026): пока он активен, любой замер стоит ещё час, а проверять надо одним вызовом. Проверка сведена к одной команде — pwsh -File tools\verify-playback-100s.ps1: до дедлайна из ynison-goaway.json она отказывается звонить (иначе взведёт ещё час), после — делает ОДИН music_play_query и 110 с читает MediaSession через dumpsys, а не music_status (тот идёт в Ynison). Ближайший запуск — после ~12:08.
  • ✅ Мёртвые параметры моста (25.10.2026). AppInstallBridge.mediaApps читал package из тела GET-запроса (fetch тела на GET не шлёт → всегда null) и прибивал limit к DEFAULT_LIST_LIMIT, хотя MediaControlRequestValidator.list и фильтр в MediaControlController.listApps уже были. Теперь query и limit читаются из query-строки и проходят валидатор; MEDIA_ACTIONS из memory.js удалён — mobile_media_control отозван, константу никто не читал.
  • ✅ Гигиена памяти проекта (26.10.2026). Правило доверия (APPLY_RULE) теперь говорит и о свежести: запись о состоянии кода должна называть дату и коммит, а без них её положено перепроверить по коду. То же требование — в описании local_memory_store, чтобы оно попадало агенту до записи, а не только при чтении. В ответах list/recall появилось поле created_at (ISO): раньше дата была только эпохой в мс, и «свежая запись или нет» приходилось угадывать. Проверено прогоном memory.js под bun на ПК (store → list → forget) и jstest (29 тестов provenance).

Нужно от пользователя перед стартом P1: разблокированный телефон с выданным SAW-грантом (SYSTEM_ALERT_WINDOW, «поверх других приложений»; замер 26.10.2026 — adb install -r его не снимает, а вот accessibility-сервис после переустановки выключен), разрешение убивать Яндекс.Музыку в экспериментах. Реалистично за день — P0 плюс один из P1: разбор effective-конфига может оказаться задачей на неделю.

⛔ Чего делать нельзя

Не «переизобретать» определение живости процессов. /proc на устройстве смонтирован с hidepid=invisible, поэтому файл живого чужого процесса не читается (Permission denied), и pidof отдаёт PID не того процесса. Признак жизни берётся у цели — её MediaSession. Обходных путей нет: getRunningAppProcesses с API 21 отдаёт только своё приложение, UsageStatsManager требует второго grant. Предупреждение есть комментарием в InstalledAppController.kt.


Статус

Трек Статус
1. Release-контур Выполнен — R8 + shrinkResources (374 → 274 → 147 MB), base вынесена в lazy, пакет чистый org.opencode.mobile, smoke OK. Подпись закрыта (26.09, 96bb02a): release подписан своим RSA-4096 ключом, keystore.properties в .gitignore, оффлайн-копия ключа сделана. Карточка Play готова (25.10): листинг в лимитах, 6 скриншотов 1272×2477, иконка 512×512, feature graphic 1024×500, политика конфиденциальности — docs/play/. Перепроверено 26.10.2026 после миграции на 2.0.27: сборка за 40 с, подпись release-ключом (CN=opencode-mobile, OU=Release), все нативы в APK (включая libopencode.so 2.0.27 и libbun-musl.so), движок и memory-MCP поднимаются за ~2 с, музыкальный MCP пропускается по делу (на чистой установке нет токена Яндекса), падений нет. Ограничение: не-debuggable сборку нельзя опросить по HTTP — пароль serve читается только через run-as. AAB теперь собирается штатной задачей -Task bundle (раньше — руками: -Task release даёт только APK, а Play APK не принимает); задача печатает versionCode, чтобы повторная загрузка с тем же номером не стала сюрпризом; targetSdk = 36, minSdk = 28, запас до лимита Play 200 МБ на base-модуль ~23 МБ. AAB пересобран 26.10.2026 после правок памяти и STT: app-release.aab 177 256 828 байт, versionCode=4, подпись на месте (META-INF/OPENCODE.RSA), ассеты сверены по размерам (иконка 512×512, feature 1024×500, шесть скриншотов 1272×2477), docs/privacy.md на месте. Следующая загрузка потребует versionCode > 4. Осталось: доступ к Play Console, URL политики и решение о номере следующей версии
2. Сеть без прокси Выполнен — встроенный Ipv4Proxy (CONNECT, IPv4-first); туннели к context7/grep/npm открываются автоматически. Legacy connect_proxy.py удалён (26.09)
3. STT turbo на CPU Почти готов — int8-encoder ~2× fp32, VAD-чанкинг внедрён (первые слова 1–2 s), бенч на устройстве прогнан (24.09), VAD-пороги измерены (26.09, 56034e9: MIN_RMS=0.015 подтверждён свипом 6 порогов × 5 SNR), WER-счётка готова и покрыта 13 тестами (26.09, c7256c1). Корпус найден и прогнан (26.10.2026): эталон лежит вне репозитория (C:\Projects\MyFLOW\data\eval_wispr), 7 клипов, whisper small — corpus WER 13,85 %; осталось прогнать весь корпус (624 клипа)
4. Безопасность и память Выполнен — Basic Auth (401 без пароля), локальная память MCP (ensureMcpConfig, фикс «0 MCP»), протокольная диагностика, e2e чистой установки пройден
5. День 2: тесты + расследования P0 закрыт (25.10.2026): AppInstallBridgeTest.kt — 10 тестов, JSON-формы вынесены в AppInstallWire/MediaWire; JS-часть уже была покрыта ynison-bridge.test.mjs и SessionControlEnumContractTest. P2-мелочи закрыты (25.10): /v1/media/apps читает query/limit из query-строки через валидатор, мёртвый MEDIA_ACTIONS удалён. P1 не закрыт: effective-конфиг MCP найден (25.10 — пишет ensureMcpConfig, поле timeout есть в схеме 2.0.27), блокировка регистрации music при подключённом moltbook найдена и исправлена (25.10, hasManagedMcpBlock/planMusicEntry — узнавали секцию целиком), замер таймаута закрыт (25.10: вызов умирает за 18,5 с на go-away 503, а не на 180 с; бан теперь узнаётся, соблюдается и переживает рестарт), seed/mirror-цикл ensurePhone() ждёт живого Ynison. Регистрация музыки по факту старта (26.10): ensureMcpConfig перенесён после startYnisonAndVerify и получает ynisonToken.takeIf { ynisonStarted } — неудачный старт снимает секцию, а не оставляет модели шесть инструментов, которые не отвечают. Шторм переподключений закрыт (26.10): 1632 обрыва server=music за 7 часов — это и был «неизвестный вызов в 22:54», лечится server.timeout(req, 0) + keepalive, см. выше