31 августа в 14:32 по Москве у нас вышла последняя штатная публикация. Дальше cron честно стартовал по расписанию, генератор успевал выбрать тему и написать текст, но в ленте ничего не появлялось. Так повторилось четыре раза: три запуска 1 сентября и утренний запуск 2 сентября.
Сначала это выглядело как обычная нестабильность внешнего генератора изображений. В логе рядом были пустые ответы текстовой модели, повторные попытки, разные темы. На деле причина оказалась приземлённее: в поле, рассчитанное на URL, уходил путь к файлу на нашем сервере.
Сам по себе этот баг стоил бы одного слота. Остальные три съела логика ротации: неудачная рубрика не помечалась использованной и на следующем запуске выбиралась снова. А заметили мы тишину не по мониторингу. Её заметил человек, открыв аккаунт через два дня.
Что было видно в журнале
Генератор пишет один файл av_social.log. Для первого разбора я искал не сообщение «cron запущен», а последние строки, после которых должна была появиться регистрация публикации.
В 162-й строке начинался вполне нормальный запуск:
тема: Первое сообщение покупателю, когда ответить по сути прямо сейчас не можете рубрика кадра: shot — скрин продукта LLM попытка 1 не вышла: пустой content (refusal=None, reasoning=0) LLM попытка 2 не вышла: пустой content (refusal=None, reasoning=0) пост: 1354 знаков | threads: 281
Текст со второй попытки был готов. Падение случилось позже:
Traceback (most recent call last): File "/root/romkola-platform/infra/avitoma-social/av_gen_post.py", line 348, in main subprocess.check_call(['python3', '/root/kie_gen.py', ...]) subprocess.CalledProcessError: Command '['python3', '/root/kie_gen.py', '/root/avitoma-social/img_prompt.txt', '/root/avitoma-social/img_last.jpg', '/root/avitoma-social/shots/blocks.jpg']' returned non-zero exit status 1.
Чуть ниже сохранился ответ задания:
taskId: 3d2494d6f57d9436173182333fd2c9aa state: fail image_input: ["/root/avitoma-social/shots/blocks.jpg"]
То же самое повторилось ещё с тремя taskId. Менялись тема и prompt, но аргумент в image_input оставался локальным:
/root/avitoma-social/shots/blocks.jpg
Для процесса на нашем VPS это существующий файл. Для удалённого сервиса это просто строка, указывающая на чужую файловую систему. Наш kie_gen.py без преобразований складывал полученные аргументы в image_input, отправлял задачу, потом опрашивал recordInfo и завершался при state из fail, failed, error.
Проверка базы дала вторую половину картины. Последние записи брендового аккаунта были 31 августа в 06:33 и 11:32 UTC. После них четыре плановых попытки не создали строк публикации. То есть процесс стартовал, текст генерировался, внешний API принимал задание и возвращал taskId, но конечного результата не было.
Это важное различие. taskId подтверждает только приём задания. Завершившийся cron подтверждает только то, что оболочка отдала управление программе. Для нас результатом считается одновременно внешний URL публикации и строка published в базе.
Откуда взялся путь
У генератора несколько рубрик для кадра. Большинство используют заранее заданные ссылки либо не используют референс вообще. Рубрика shot должна брать настоящий скрин интерфейса из каталога:
shots = sorted( f for f in os.listdir(SHOTS) if f.lower().endswith(('.jpg', '.jpeg', '.png')) ) refs.append(os.path.join(SHOTS, shots[0]))
Пока каталог был пуст, эта ветка фактически спала. 30 августа туда положили десять скриншотов. На следующем выборе shot код впервые собрал полный локальный путь и передал его дальше как готовый референс.
Это была моя первая ошибка в разборе: я смотрел изменения клиента внешнего API. Клиент не менялся. Проснулась старая ветка, которую раньше не выполняли с непустым каталогом.
Вторая ошибка — я сначала считал четыре падения независимыми. Они были одной цепочкой. Рубрика выбиралась по давности использования, а отметка об использовании записывалась лишь в самом конце успешного запуска. После падения shot оставалась самой «давней» и выигрывала ротацию снова.
Получился маленький детерминированный цикл:
shot выбрана -> локальный путь попал в image_input -> генерация изображения упала -> публикация не зарегистрирована -> shot не отмечена использованной -> на следующем слоте снова выбрана shot
Ретраи вокруг API здесь не помогли бы. Каждый повтор отправлял бы один и тот же недоступный путь.
Починка
У нас уже была рабочая функция загрузки изображений для самой публикации. Я использовал её до вызова генератора кадра. Теперь локальный файл сначала превращается во внешнюю ссылку, и только она добавляется к референсам:
import random shots = sorted( f for f in os.listdir(SHOTS) if f.lower().endswith(('.jpg', '.jpeg', '.png')) ) local = os.path.join(SHOTS, random.choice(shots)) try: shot_ref = av_pub.upload(local) print('скрин для кадра:', os.path.basename(local), '→', shot_ref) refs.append(shot_ref) except Exception as exc: print('скрин не залился (%s) — кадр без скрина' % str(exc)[:120])
Заодно убрал всегда первый файл. Это не исправление аварии, но без него десять скриншотов в каталоге создавали иллюзию ротации: реально использовался только shots[0].
Одной загрузки было недостаточно. Ссылка может успешно появиться, а сам референс может не пройти обработку. Поэтому вызов генератора получил один ограниченный повтор без скриншота:
kie = [ 'python3', '/root/kie_gen.py', os.path.join(ROOT, 'img_prompt.txt'), img, ] try: subprocess.check_call(kie + refs, env=env) except subprocess.CalledProcessError: if not shot_ref: raise subprocess.check_call( kie + [ref for ref in refs if ref != shot_ref], env=env, )
Здесь обнаружилась ещё одна неприятная мелочь. Нельзя было просто убрать ссылку и оставить прежний prompt. Хвост рубрики требовал сохранить экран резким и неизменным. Без референса модель нарисовала бы выдуманный интерфейс, хотя правило рубрики как раз запрещало это.
Перед повтором мы заменяем хвост shot на нейтральный хвост другой рубрики. Получается честный запасной кадр без изображения продукта, а не синтетический «скрин».
Что не сработало
Первый тупик — искать проблему по расписанию. В /etc/cron.d/avitoma-social все три строки были на месте, процесс запускался в 06:30, 11:30 и 16:30 UTC. Исправлять cron было нечего.
Второй тупик — считать пустые ответы текстовой модели общей причиной тишины. В одном запуске она действительно не отдала текст за три попытки:
RuntimeError: LLM не отдал текст за 3 попытки: пустой content (refusal=None, reasoning=0)
Но в трёх других текст был готов: 1354, 1113, 1266 и 1271 знак в разных запусках. Их объединяло падение после выбора shot, а не поведение текстовой модели.
Третий тупик — смотреть лог в /root/avitoma-social. Рабочий каталог cron совпадал с каталогом клона, поэтому актуальный файл лежал в /root/romkola-platform/infra/avitoma-social/av_social.log. Старая директория рядом выглядела правдоподобно и отнимала время.
Наконец, сначала хотелось ограничиться try/except и сообщением в Telegram. Это сделало бы падение заметным, но не остановило бы повторный выбор той же рубрики. Нужен был исправленный вход для генератора и запасной путь, который доводит слот до публикации.
Сторож измеряет не процесс
До этого инцидента единственным сигналом был сам журнал. Ошибка там была, но никто не обязан читать его после каждого слота.
Теперь отдельный скрипт стартует через 40 минут после плановой публикации. Он не проверяет PID, код возврата cron или наличие свежих строк в логе. Запрос считает опубликованные записи конкретного профиля за последние 100 минут:
SELECT count(*) FROM app_publications pub JOIN app_platforms pl ON pl.id = pub.platform_id JOIN app_profiles p ON p.id = pl.profile_id WHERE p.code = 'avitoma_brand' AND pub.platform_kind = 'instagram' AND pub.status = 'published' AND pub.posted_at > now() - interval '100 minutes';
Ноль означает пропущенный слот и вызывает алерт. Это намеренно грубая проверка. Ей неважно, на каком шаге остановилась цепочка: не выбралась тема, не получился кадр, не приняла публикацию площадка или не записалась база. Обещанный результат один — публикация.
В самом генераторе верхний уровень тоже перестал молча отдавать traceback только в файл. Исключения теперь отправляют короткое сообщение и затем пробрасываются дальше. SystemExit с ненулевым кодом обрабатывается отдельно, потому что отсутствие темы или запрет канона раньше завершали процесс иначе, чем обычная ошибка Python.
Проверка после исправления
Боевой запуск 2 сентября выбрал ту же рубрику shot. В журнале впервые появилась промежуточная ссылка:
пост: 1256 знаков | threads: 266 залито через catbox-warp: https://files.catbox.moe/h97hbo.jpg скрин для кадра: home-cards.jpg → https://files.catbox.moe/h97hbo.jpg
Дальше цепочка дошла до реальных артефактов:
IG: https://www.instagram.com/p/DcyJEDxjtNL/ Threads: 17946486462054681 сторис: 18115355204065144 записано в платформу, тема отмечена использованной
До исправления: четыре последовательных запуска рубрики, четыре state: fail, ноль публикаций этих слотов. После исправления: первый же запуск с shot загрузил выбранный файл по URL, опубликовал материал в двух площадках и записал результат в платформу. Повтор без референса в этом прогоне не понадобился.
Самая дорогая часть оказалась не в неправильной строке refs.append(...). Два дня тишины появились из сочетания трёх решений: редко исполняемая ветка не проверялась с реальным файлом, неуспех сохранял рубрику первой в очереди, а наблюдение следило за механизмом, не за результатом. После такой связки один локальный путь вполне способен съесть все следующие слоты, пока кто-нибудь не откроет ленту руками.

