Паттерны Cron-агентов
Многоуровневая защита таймаутами для cron-агентов, вызывающих внешние API (LLM CLI, HTTP, subprocess) — вложенные уровни, антипаттерны, запас времени.
Обзор
Cron-агент, вызывающий внешние API (LLM CLI, HTTP-клиент, subprocess), может зависнуть, выйти за рамки своего окна или наложиться на следующий cron-тик. Этот скилл защищает от этого вложенными таймаутами — каждый уровень строго меньше объемлющего — плюс симметричные проверки дедлайна и явный запас времени для следующего fallback-уровня. Загружайте при реализации или ревью любого агента, который запускается по расписанию (cron, systemd timer, sleep-цикл) и делает вызовы, способные заблокироваться бесконечно.
Лестница вложенных таймаутов
Упорядочьте таймауты так, чтобы каждый внутренний бюджет был строго меньше объемлющего. Нарушение строгой вложенности означает, что внутренний уровень никогда не сработает раньше, чем его вытеснит внешний — внутренняя защита становится мёртвым кодом.
- (a) Таймаут одного вызова — ограничивает каждый отдельный внешний вызов (HTTP read timeout, subprocess timeout, LLM CLI
--timeout). Должен помещаться в (b) с учётом ретраев и fallback'ов. - (b) Бюджет дедлайна цикла — монотонный дедлайн вычисляется один раз в начале цикла; проверяется перед каждым внешним вызовом.
- (c) Страховка SIGALRM — таймаут на уровне библиотеки не может прервать вызов, заблокированный в syscall на уровне C; жёсткий alarm принудительно возвращает процесс в обработчик.
- (d) Внешняя защита на уровне shell — весь вызов оборачивается в
timeout --kill-after=<grace> <hard-limit> <cmd>, последний рубеж защиты, если всё внутри процесса зависло.
Паттерн не привязан к стеку — названия уровней применимы к любому рантайму (Python signal/subprocess, Node timers/AbortController, Go context, shell timeout).
Антипаттерны
Каждый из них вызвал реальный продакшен-инцидент:
- Таймаут вызова равен бюджету цикла. Если лимит одного вызова равен всему бюджету цикла, математика запрещает вызову когда-либо вписаться вместе с ретраями или fallback следующего уровня.
- Floor вида
max(N, deadline - now). Ограничение снизу константой N повторно раздувает обречённый вызов уже после того, как дедлайн прошёл. Никогда не floor'ите оставшийся бюджет — если он ноль или отрицателен, пропускайте вызов. - Проглатывание alarm через
except Exception: pass. Голый обработчик исключений вокруг тела вызова молча съедает исключение, поднятое SIGALRM, и страховка никогда не срабатывает. Исключение таймаута нужно перевыбросить до общего обработчика.
Обязательные защиты
- Симметричные проверки истечения дедлайна на каждом fallback-уровне. Цепочка fallback (основной → вторичный → третичный провайдер) должна проверять дедлайн перед каждым уровнем, а не только перед первым.
- Явный резерв времени для следующего fallback-уровня. Вычитайте именованную резервную константу из бюджета, выдаваемого каждому уровню, чтобы fallback успел отработать после таймаута основного. Без резерва основной вызов съедает весь бюджет, и fallback обречён с самого начала.
Чеклист
- Каждый уровень таймаута строго меньше объемлющего (a<b<c<d)
- Дедлайн цикла вычислен один раз монотонными часами в начале цикла
- Таймаут вызова никогда не floor'ится выше оставшегося бюджета
- Дедлайн проверяется перед каждым внешним вызовом и каждым fallback-уровнем
- SIGALRM (или эквивалент) взведён для заблокированных C-syscall'ов
- Исключение таймаута перевыбрасывается до любого общего обработчика
- Явный резерв зарезервирован для следующего fallback-уровня
timeout --kill-afterв shell оборачивает весь вызов как внешняя защита