Как DSH Mobile дожидается ответа ИИ-агента без пушей Google

—

от автора

Отправил агенту задачу с телефона, убрал телефон в карман и хочешь узнать, когда он закончит. Обычно для этого берут пуши Google (FCM). У DSH Mobile облачного сервера нет, телефон разговаривает напрямую с компьютером, на котором работает агент. В версиях 0.9.x я переделал уведомления. Постоянного соединения в простое больше нет, а за отправленной задачей телефон следит, даже если Android выгрузил приложение или телефон перезагрузили. Расскажу, как это устроено и какие четыре ошибки нашлись по дороге. Самая заметная съедала 11 секунд на каждом ответе агенту, самая неприятная обрывала загрузку фото на 5 МБ.

Коротко о проекте

DSH Mobile это неофициальный Android-клиент к DeepSeek Harness, среде, в которой ИИ-агенты работают на компьютере. С телефона можно открыть чат проекта, поставить задачу, отправить фото и ответить агенту на вопрос. Как устроены плагин на компьютере и привязка телефона, я рассказывал в предыдущей статье. Код проекта пишут ИИ-агенты под моим руководством, я проектирую, проверяю и выпускаю. Проект не связан с DeepSeek и не одобрен ею, код открыт под MIT.

Дальше речь про приложение и плагин 0.9.2 с DSH 0.2.1-rc.5. В 0.9.0 приложение перестало показывать общую ленту уведомлений и следит только за задачами, отправленными с этого телефона. Старую ленту и ее настройки я удалил.

Квитанция еще не результат

Когда телефон отправляет задачу, плагин на компьютере возвращает квитанцию. Она значит только то, что плагин команду принял. Модель после этого может работать еще несколько минут, задать вопрос или упасть с ошибкой.

Чтобы узнать, чем все кончилось, я добавил отдельный запрос GET /v1/sessions/{sessionId}/prompt-outcomes. Он ничего не отправляет и не возобновляет, только читает. И отвечает лишь про задачи, на которые у этого устройства есть квитанция. Если спросить про чужую задачу или задачу, отправленную с компьютера, придет unknown, так что по ответу нельзя понять даже то, существует ли она.

Состояние плагин берет из журнала DSH, а не из того, что видно в окне чата. Статусов пять.

  • queued, запись лежит в очереди. Если она из очереди пропала, это еще не значит, что задача запустилась.

  • running, в начавшемся ходе агента есть сообщение user/message с тем же source.rpcId.

  • waiting, ход ждет ответа на вопрос или подтверждения действия.

  • completed или failed, ход с этим запросом завершился с причиной completed или error.

  • unknown во всех остальных случаях. Например, ход отменили или прервали, или плагин еще не дочитал журнал до конца.

Позицию чтения журнала плагин хранит в SQLite. Поэтому после перезапуска компьютера разбор продолжается с того же места, даже если старые сообщения уже ушли из окна чата. Текст задачи и аргументы инструментов туда не попадают.

Проще было бы считать, что если давно ничего не приходило, значит, задача закончилась. Но тогда телефон иногда писал бы «Ответ готов» про задачу, которая еще работает. Поэтому вместо догадки плагин отвечает unknown.

При waiting телефон показывает «Нужен ответ» и следит дальше. Ответить на вопрос агента можно с телефона, а подтверждать его действия по-прежнему нужно на компьютере. Видеть, что агент ждет подтверждения, и давать его это разные права.

Две дорожки, команда и наблюдение. Восстановить наблюдение не значит повторить действие

Две дорожки, команда и наблюдение. Восстановить наблюдение не значит повторить действие

Long polling вместо постоянного соединения

Состояние задачи телефон узнает через long polling, отдельно от потока SSE, по которому идет открытый чат. Сервер отдает непрозрачный токен текущего результата. Следующий запрос с этим токеном сервер может держать, пока состояние не изменится или не выйдет время. Если время вышло, а токен прежний, сервер отвечает обычным 200, это не ошибка. После восстановления приложения, возврата на экран или переподключения первая проверка идет сразу, без ожидания.

Все запросы опроса и очередь уведомлений обслуживает один координатор, поэтому одновременно висит не больше одного запроса. Если задача одна, запрос держится до 25 секунд. Если задачи идут в нескольких чатах, каждому чату за проход достается не больше секунды. Мгновенной доставки это не дает. Полный обход 32 групп может включать 32 секундных ожидания плюс сеть и работу компьютера. Когда сети нет, цикл засыпает, пока она не появится. Когда сеть есть, а компьютер недоступен, это ошибка соединения, и повтор идет с ограниченной задержкой.

Пока телефон за чем-то следит, работает foreground-служба со своей карточкой в шторке. Убрать эту карточку на всех телефонах нельзя, поэтому обещать пустую шторку я не стал. Обещание другое. Когда последняя задача завершилась и уведомление о ней показано, служба останавливается.

Карточка службы во время задачи

Карточка службы во время задачи

Пока задача идет, работает foreground-служба с карточкой «Ждём результат задачи»

Уведомление о готовом ответе

Уведомление о готовом ответе

После завершения приходит «Ответ готов» с названием чата

На моем телефоне 3 ноября служба во время задачи работала как foreground, а после результата dumpsys activity services dev.dshmobile.app уже не показывал ни одной службы приложения. Это одно наблюдение на одном телефоне, за все прошивки я не ручаюсь.

Сам чат с тестовой задачей
Тестовая задача и ответ в чате «Проверка уведомлений»

Тестовая задача и ответ в чате «Проверка уведомлений»

Тестовая задача в чате «Проверка уведомлений»

Если Android выгрузил приложение

Перед отправкой команды телефон сохраняет наблюдение, то есть sessionId, requestId и срок действия. Лежит оно в отдельной зашифрованной записи, отдельно от черновиков и квитанций. Если плагин явно отказал или точно известно, что команда не ушла, наблюдение удаляется. Если доставка под вопросом, оно остается. Поэтому после сбоя телефону не нужно отправлять задачу еще раз, чтобы узнать ее судьбу. Он просто снова спрашивает про результат.

Восстановление работает так.

  • Когда Android завершает процесс, служба возвращает START_STICKY, и система поднимает ее снова.

  • После перезагрузки телефона и разблокировки, а также после обновления приложения, запуск делает WatchRestoreReceiver.

  • Перед стартом проверяется, что есть живые наблюдения или очередь уведомлений, сохранена привязка и разрешены уведомления.

  • После старта приложение еще раз убеждается, что плагин поддерживает promptOutcomes. Саму задачу оно повторно не отправляет.

Если остановить приложение вручную в настройках Android, ничего само не поднимется, приложение нужно открыть.

Наблюдение живет 24 часа с момента отправки. Одновременно хранятся до 32 наблюдений и до 32 еще не показанных уведомлений, при переполнении остаются новые. Уведомление подписано названием чата, и по нажатию открывается этот чат. «Нужен ответ» исчезает, когда чат открыли или на вопрос ответили, с телефона или с компьютера.

То, что задача не уходит повторно, еще не значит, что уведомление придет ровно один раз. Перед вызовом NotificationManager координатор сохраняет карточку в outbox с отметкой о попытке. После показа он убирает ее оттуда и запоминает ключ доставки.

update { it.copy(outbox = it.outbox.map { item -> if (item.key == alert.key) item.copy(attempted = true) else item }) }post(alert, alert.attempted)update { it.acknowledgeWatchAlert(alert.key, now()) }

Показ уведомления стоит между двумя записями. Если процесс умрет после показа, но до второй записи, outbox обработается еще раз. Повторное уведомление идет с тем же тегом чата и onlyAlertOnce, поэтому заменяет первое, а не ложится рядом. Но если первое уже смахнули, его покажут снова. Бывает и наоборот. Если процесс умрет после отметки о попытке, но до показа, восстановленное уведомление придет без звука.

Четыре ошибки, которые нашлись по дороге

17 секунд на ответ агенту

Агент задает вопрос, я отвечаю с телефона и жду. В предрелизной сборке от нажатия до моего ответа в карточке и реакции агента проходило около 17 секунд. Выяснилось, что этот путь четыре раза читал полный список сессий, а у меня на компьютере их больше 4000. Одни эти чтения занимали около 11 секунд.

В коммите cf77a14 полный обход убрали. Теперь адаптер читает заголовок только нужной сессии через pointScope, сверяет рабочее пространство и после ожидания проверяет еще раз. Повторная проверка доступа осталась, просто читается меньше данных. Заодно проверяется состояние сессии, чтобы ответ не ушел в сессию, которую за это время перенесли или архивировали. В регрессионных тестах обращение к полному списку специально падает с ошибкой.

После исправления ответ появлялся через 2–4 секунды. Экран я снимал раз в 2 секунды, поэтому точнее сказать не могу. И это разные измерения. 11 секунд ушло только на чтение списка, а 17 и 2–4 секунды это весь путь от нажатия до ответа агента на одном моем телефоне.

Ответ пропадал после перезапуска DSH

Когда агент задает вопрос встроенным инструментом, под вопросом в карточке остается строка «Ответ: …». В 0.9.0 она жила в памяти плагина и пропадала после перезапуска DSH. С 0.9.1 ответы сохраняются в SQLite-базе плагина, включая ответ с компьютера и пропуск вопроса. Хранится до 20 ответов на чат и до 128 всего, каждый 24 часа, и перезапуск этот срок не продлевает. Номер схемы базы остался 4. Если откатиться на старую версию, она новую таблицу просто не заметит.

Первая независимая проверка нашла проблему. Много длинных сохраненных ответов раздували снимок чата так, что он переставал загружаться. Теперь история ответов в снимке ограничена 1 МиБ JSON. В снимок попадают самые новые записи целиком, ответ посередине не обрезается.

Отбор сохраненных ответов по размеру JSON
export function snapshotSettledQuestions(questions: readonly ResolvedQuestion[], maxBytes = SNAPSHOT_ANSWER_BYTES): ResolvedQuestion[] {  const selected: ResolvedQuestion[] = [];  let bytes = 2; // []  const budget = Math.min(SNAPSHOT_ANSWER_BYTES, maxBytes);  for (let i = questions.length - 1; i >= Math.max(0, questions.length - LEGACY_ANSWERS_PER_SESSION); i--) {    const question = questions[i]!;    const size = Buffer.byteLength(JSON.stringify(question), 'utf8') + (selected.length ? 1 : 0);    if (bytes + size > budget) continue;    selected.push(question); bytes += size;  }  return selected.reverse();}

Код из host/src/question-history.ts в теге v0.9.2.

Тесты заново открывают настоящую SQLite-базу и проверяют HTTP-снимок на синтетических сессиях. 3 ноября агент проверил это и на телефоне. После холодного перезапуска DSH и принудительной остановки приложения карточка вопроса по-прежнему показывала «Ответ: Beta», данный до перезапуска.

Задачу удалили из очереди, а телефон все ждет

В 0.9.1 я исправил удаление сообщения из очереди. Пока плагин подтверждал удаление, интерфейс успевал показать ошибку, а теперь в это время видно «Удаляем…». Но фоновый наблюдатель продолжал ждать результата задачи, которой в очереди уже нет, и карточка «Ждём результат задачи» так и висела.

В 0.9.2 удаление из очереди закрывает ожидание со статусом rejected. Записи, оставшиеся от 0.9.1, при запуске проверяются заново, так что уже зависшие ожидания тоже закрываются. На моем телефоне после обновления так закрылись два старых ожидания.

Загрузка фото обрывалась примерно на 5 МБ

Если телефон и компьютер не в одной сети, связь может идти через relay. Это небольшой сервер-посредник, он пересылает между ними зашифрованные данные и содержимого не видит. Чтобы relay нельзя было завалить потоком мелких сообщений, он пропускает не больше 64 сообщений короче 256 байт в секунду на соединение.

Крупная загрузка идет большими блоками, но блок TLS может заканчиваться коротким хвостом записи. Эти хвосты попадали под тот же лимит. Как только за секунду их набиралось больше 64, relay закрывал поток, и загрузки примерно от 5 МБ обрывались. В 0.9.2 каждый блок от 256 байт возвращает один слот лимита.

    // Opaque TLS chunks may end in short record tails. Charge small messages against    // a bounded burst, replenishing one slot per substantial chunk (never a lifetime cap).    if (bytes.length >= 256) { meta.smallCount = Math.max(0, meta.smallCount - 1); return true; }    return smallRate(meta);

Это relay/src/server.ts, такую же логику я добавил в коннектор плагина. Поток из одних коротких сообщений по-прежнему ограничен. Поэтому одного нового APK мало, обновить нужно и relay, и плагин.

В приложении у потоковой отправки вложений теперь свой таймаут, 120 секунд вместо 15. Это предел длительности запроса, а не обещанное время доставки. Фото HEIF и HEIC на Android 9 и новее перед отправкой переводятся в JPEG. При декодировании картинка уменьшается до 4096 пикселей по стороне и 16 МиБ памяти, JPEG сохраняется в sRGB с качеством 90. Лимит вложения остался 20 МиБ. Для разбора проблем с отправкой появилась диагностика в logcat с тегом DshMobileUpload.

Еще две защиты на компьютере

Если базу плагина восстановили из резервной копии. Команда могла выполниться уже после того, как сняли копию, а ее квитанции в восстановленной базе нет. Поэтому рядом с файлом SQLite плагин хранит отдельный файл <statePath>.ledger.json с идентификатором журнала, эпохой и счетчиками принятых и завершенных команд. Если этот файл знает о более новой эпохе или больших счетчиках, чем база, плагин понимает, что базу откатили, и включает recovery hold. Новые изменяющие запросы получают 503 recovery_hold еще до приема, без квитанции и без передачи в DSH. Читать историю и искать квитанции при этом можно.

Дальше решает владелец. Он смотрит recovery-status, сверяет непонятные и пропавшие команды с историей DSH и снимает блокировку через recovery-release --new-epoch. Новая эпоха фиксирует его решение, но не доказывает, что потерянная команда не выполнялась. Если файл откатили вместе с базой или потеряли, сам плагин ничего не заметит. На такой случай в инструкции написано вручную включить recovery-hold, прежде чем плагин начнет принимать команды.

Не больше 32 команд на приеме. Плагин держит не больше 32 принятых, но еще не переданных до конца команд. Следующая получает 429 rate_limited еще до приема и без квитанции. Для команд с вложениями лимит меньше, 16. Если передача висит 10 минут, квитанция становится uncertain, а передача получает сигнал отмены. Место при этом освобождается только тогда, когда вызов действительно завершится, иначе по таймауту освободилось бы место, которое старый вызов еще занимает. Команду с неясной судьбой приложение само не повторяет. Сначала нужно посмотреть на компьютере, выполнилась ли она.

Что проверено и что нет

Автотесты релиза 0.9.2 такие. Приложение прошло 499 из 499 модульных тестов, lintRelease дал 0 ошибок и 40 предупреждений. Плагин прошел 374 из 374, relay 19 из 19. Это цифры из описания релиза, для статьи я их заново не запускал.

На моем OnePlus с Android 16 агент проверял каждую версию.

  • В 0.9.0 «Ответ готов» приходил после того, как процесс приложения завершили, и после перезагрузки телефона с разблокировкой. Переключение между Wi-Fi и мобильной сетью с полным обрывом связи прошло без повторной отправки команды.

  • В 0.9.1 сохранился ответ на вопрос, а удаленное из очереди сообщение не дошло до агента.

  • В 0.9.2, 3 ноября 2026 года, версия встала поверх 0.9.1 с сохранением привязки, два зависших ожидания закрылись со статусом rejected, и через relay ушли четыре фото. Каждое получило HTTP 201, агент ответил по содержимому. PNG на 21 866 байт загрузился за 7,4 с, HEIC, переведенный в JPEG на 231 274 байта, за 7,5 с, крупный JPEG на 5 155 222 байта за 7,8 с, свежий снимок камеры на 2 669 234 байта за 8,0 с.

Не проверены загрузка напрямую по локальной сети с телефона и сочетания старого плагина с новым relay и наоборот. Уведомление об ошибке задачи на реальном телефоне я не видел, потому что безопасного способа специально вызвать такую ошибку не нашлось. В режиме Doze уведомление приходило, но задержку я не измерял. Ограничения фоновой работы у производителя, отсутствие сети или запрет уведомлений могут задержать сигнал или потерять его. Гарантии «ровно один раз» нет ни для уведомлений, ни для команд.

Итог

Приложение обещает немного. Оно продолжает следить за уже отправленной задачей и не повторяет ее, а угадывать результат по тишине не пытается. Агент работает на компьютере, телефон только ставит задачи и узнает результат. Если связь оборвалась в неудачный момент, результат остается неизвестным, пока его не покажет журнал DSH или пока я сам не посмотрю на компьютере.

Исходники лежат на GitHub, приложение и плагин можно скачать в релизе 0.9.2.

ссылка на оригинал статьи https://habr.com/ru/articles/1103324/