Внутреннее устройство cron

Подсистема cron отвечает за выполнение задач по расписанию — от простых одноразовых отложенных запусков до повторяющихся задач с cron-выражениями, подгрузкой навыков и доставкой на любую платформу.

Ключевые файлы

ФайлНазначение
cron/jobs.pyМодель задачи, хранение, атомарная запись/чтение jobs.json
cron/scheduler.pyЦикл планировщика — поиск подошедших задач, выполнение, учёт повторов
tools/cronjob_tools.pyРегистрация и обработчик инструмента cronjob_manage, видимого модели
gateway/run.pyИнтеграция со шлюзом — тик cron внутри долгоживущего цикла
hermes_cli/cron.pyПодкоманды CLI hermes cron

Модель расписания

Поддерживаются четыре формата расписания:

ФорматПримерПоведение
Относительная задержка30m, 2h, 1dОдноразовый запуск после указанного интервала
Интервалevery 2h, every 30mПовторяющийся запуск через равные промежутки
Cron-выражение0 9 * * *Стандартный 5-полевой cron-синтаксис (минута, час, день, месяц, день недели)
ISO-время2025-01-15T09:00:00Одноразовый запуск в точно указанное время

Модели доступен единый инструмент cronjob_manage с операциями в стиле действий: create, list, update, pause, resume, run, remove.

Хранение задач

Задачи хранятся в ~/.hermes/cron/jobs.json с атомарной семантикой записи (запись во временный файл с последующим переименованием). Каждая запись задачи содержит:

{
  "id": "a1b2c3d4e5f6",
  "name": "Daily briefing",
  "prompt": "Summarize today's AI news and funding rounds",
  "schedule": {
    "kind": "cron",
    "expr": "0 9 * * *",
    "display": "0 9 * * *"
  },
  "skills": ["ai-funding-daily-report"],
  "deliver": "telegram:-1001234567890",
  "repeat": {
    "times": null,
    "completed": 42
  },
  "state": "scheduled",
  "enabled": true,
  "next_run_at": "2025-01-16T09:00:00Z",
  "last_run_at": "2025-01-15T09:00:00Z",
  "last_status": "ok",
  "created_at": "2025-01-01T00:00:00Z",
  "model": null,
  "provider": null,
  "script": null
}

Значения last_status

last_status — закрытый набор значений, писать их имеет право только cron.jobs.mark_job_run. Каждый потребитель (hermes cron list/doctor, инструмент cronjob_manage, бейдж веб-панели, инспектор рутин в десктопе) разбирает эти значения по отдельности: проверка == "ok" в смысле «пользователь получил результат» недопустима.

ЗначениеЧто означаетПоле с подробностями
okЗапуск агента прошёл успешно и доставка (если задана цель) подтверждена—
errorЗапуск агента сорвалсяlast_error
delivery_failedАгент отработал, но результат до цели не дошёлlast_delivery_error (last_error равен null)
blocked_configПроверка перед отправкой не дала потратить запуск впустуюlast_error

Состояния жизненного цикла задачи

СостояниеЗначение
scheduledАктивна, сработает в следующее запланированное время
pausedПриостановлена — не сработает до возобновления
completedСчётчик повторов исчерпан, либо одноразовая задача уже сработала
runningВыполняется прямо сейчас (переходное состояние)

Обратная совместимость

У старых задач вместо массива skills может встречаться одиночное поле skill. Планировщик нормализует это при загрузке — одиночный skill превращается в skills: [skill].

Среда выполнения планировщика

Цикл тика

Планировщик работает по периодическому тику (по умолчанию — каждые 60 секунд):

tick()
  1. Захватить блокировку планировщика (не даёт тикам перекрываться)
  2. Загрузить все задачи из jobs.json
  3. Отфильтровать подошедшие задачи (next_run <= now И state == "scheduled")
  4. Для каждой подошедшей задачи:
     a. Установить состояние "running"
     b. Создать новую сессию AIAgent (без истории переписки)
     c. Загрузить прикреплённые навыки по порядку (внедряются как сообщения пользователя)
     d. Прогнать промпт задачи через агента
     e. Доставить ответ в настроенный пункт назначения
     f. Обновить run_count, вычислить next_run
     g. Если счётчик повторов исчерпан → state = "completed"
     h. Иначе → state = "scheduled"
  5. Записать обновлённые задачи обратно в jobs.json
  6. Снять блокировку планировщика

Контракт для пропущенных срабатываний (паузы из-за перезапусков)

Повторяющиеся задачи выполняются не более одного раза на каждое срабатывание, и каждое срабатывание учтено: оно либо выполняется (одна строка выполнения со своим scheduled_instant), либо его пропуск логируется с причиной. Срабатывание никогда не теряется молча. Механика в том порядке, в котором её применяет сканирование подошедших задач (cron/jobs.py::_evaluate_due_job):

  1. Сдвиг до диспетчеризации — предварительный. tick() продвигает next_run_at за подошедшее срабатывание до диспетчеризации, чтобы падение посреди запуска не перезапускало его при каждом рестарте. Поскольку так остаётся окно — сдвиг сделан, а заявка на срабатывание ещё нет (интерпретатор завершается, executor отказывается брать работу, SIGKILL), — сканирование в том же сохранении ставит на запись штамп pending_slot = {scheduled_at, at, by}. claim_job_for_fire (точка, после которой уже могут быть побочные эффекты) и mark_job_run его снимают; явная перезапись schedule / next_run_at / enabled / state (правка, пауза, возобновление, запуск сейчас) его сбрасывает.
  2. Восстановить один раз. Более позднее сканирование, нашедшее pending_slot, чей владелец доказуемо исчез (этот процесс, а задачи нет в его наборе выполняющихся; другой процесс, у которого истекла 300-секундная аренда заявки на срабатывание или мёртвый pid), возвращает scheduled_at обратно в next_run_at, снимает штамп и пишет WARNING (cron/occurrences.py::unclaimed_pending_slot). Так происходит не больше одного раза на потерянное срабатывание — восстановленный момент дальше подчиняется обычным правилам ниже, как любой другой просроченный слот, так что повтора N слотов не бывает.
  3. Уже сработало → никогда дважды. completed_occurrence() сверяется с журналом выполнений на предмет строки completed с точно таким scheduled_instant, прежде чем что-либо будет считаться подошедшим; слот, выполнившийся до перезапуска, сдвигается без срабатывания. Строки failed / unknown завершением не считаются, как и строка completed, чей finished_at (иначе claimed_at) раньше момента, которым она помечена, — запуск не может подтвердить срабатывание, которого ещё не было. Строки без сопоставимой метки времени продолжают учитываться. Идентичность срабатывания можно заявить, только когда оно подошло: claim_job_for_fire отбрасывает scheduled_instant, который ещё в будущем, поэтому срабатывание вне тика (запуск из дашборда, вебхук, перехват аренды, страховка от пропусков) выполняется без привязки к срабатыванию, а не съедает следующий слот.
  4. Опоздание в пределах отсрочки → сработать с опозданием. Отсрочка = половина периода, ограниченная диапазоном [120 с, 2 ч] (_compute_grace_seconds); диспетчеризация помечается last_dispatch.kind = late.
  5. Позже отсрочки → схлопнуть накопившееся, сработать один раз (kind = catch_up) либо пропустить с записью причины в лог, если оператор задал cron.catch_up_missed: false (плановый простой). Одноразовые задачи, просрочившие свои 120 с отсрочки, снимаются с диагностикой и никогда не воскрешаются.
  6. Приостановленные / отключённые / завершённые задачи никогда не срабатывают; сканирование отбрасывает их до всего перечисленного, а пауза/возобновление снимает любой ожидающий слот. Однако повторяющееся срабатывание, срок которого подошёл во время паузы, не теряется: resume_job сохраняет прошедший записанный next_run_at как момент срабатывания, а не пересчитывает его от текущего времени (и пишет об этом в лог), так что первый тик после возобновления применяет к нему правила 3–5 — один запоздалый/догоняющий запуск или пропуск с записью в лог. Одноразовые задачи и моменты в будущем при возобновлении пересчитываются от текущего времени.

Одни и те же поля хранилища управляют любой топологией: отдельный hermes -p X gateway run и профиль, обслуживаемый шлюзом хоста (_start_multiplex тикает каждый дом под _profile_cron_scope), оценивают одну и ту же запись. На хосте один процесс шлюза тикает хранилища всех профилей — gateway.multiplex_profiles управляет адаптерами, а не cron, — а учёт по запускам (заявки на выполняющиеся задачи, параллельный пул воркеров, решение об уступке из-за устаревшего кода) ведётся по домашнему каталогу профиля, так что у двух профилей могут быть задачи с одинаковыми именами без коллизий. Профиль, у которого свой шлюз, на каждом тике пропускается, поэтому два процесса никогда не соревнуются за его хранилище, а его доставки всегда уходят через его собственные живые адаптеры.

Аренда заявки на срабатывание во время запуска. Срабатывающий запуск держит fire_claim = {at, by}, а поток-пульс обновляет at каждые 60 с (аренда — 300 с). Если замер пульса показывает, что заявка чужая, перед учётом он перепроверяется ещё раз: выполняющийся запуск отменяет только подтверждённая потеря. Но и тогда итог запуска определяется по хранилищу в момент завершения, а не по этой защёлке: заявка, которую хранилище всё ещё признаёт действительной, записывает настоящий результат запуска (ok или настоящую ошибку), а действительно перехваченная заявка отбрасывает устаревший результат и никогда не пишет поверх нового владельца. Поэтому Interrupted by shutdown before terminal completion. записывается, только когда реальная отмена транспорта (слив шлюза) останавливает запуск, который всё ещё держит свою заявку.

Интеграция со шлюзом

В режиме шлюза cron-триггер (часть, которая решает, когда сработает подошедшая задача, — «ось B») выбирается через подключаемый провайдер CronScheduler. Шлюз вызывает resolve_cron_scheduler() (cron/scheduler_provider.py) и запускает метод start() выбранного провайдера в отдельном фоновом потоке, параллельно с отдельным потоком служебных задач шлюза.

Активный провайдер выбирается ключом конфигурации cron.provider:

  • пусто (по умолчанию) → встроенный InProcessCronScheduler, который запускает исторический внутрипроцессный цикл, вызывающий scheduler.tick() каждые 60 секунд. Поведение побайтово идентично тому, что было до появления провайдеров.
  • именованный провайдер (например, chronos — управляемый провайдер cron для развёртываний со scale-to-zero) → обнаруживается в plugins/cron_providers/<name>/ или $HERMES_HOME/plugins/<name>/.

Если именованный провайдер отсутствует, не загружается или сообщает is_available() == False, резолвер откатывается на встроенный вариант с предупреждением в логе — cron никогда не остаётся без триггера. Встроенный провайдер живёт в ядре (cron/scheduler_provider.py), а не в plugins/, поэтому этот резервный вариант нельзя случайно удалить.

То, что означает «срабатывание» (выполнение задачи + доставка), не меняется и общее для всех провайдеров — это по-прежнему находится в scheduler.run_job() / scheduler._deliver_result(). Провайдер управляет только триггером, но никогда — выполнением.

Тикер, чей checkout обновили у него из-под ног (ревизия при загрузке ≠ ревизия на диске), уступает тик только тому шлюзу, который действительно может его перенять: держатель блокировки среды выполнения должен быть живым шлюзом со свежим пульсом в gateway_state.json и проставленным code_sha, равным ревизии на диске. Блокировка, которую держит процесс, сам ещё работающий на коде до обновления, — обычный случай сразу после hermes update с одним шлюзом, — свежим шлюзом не считается никогда, так что тикер продолжает запускать задачи, а не уступает каждый тик в пустоту.

В режиме CLI задачи cron срабатывают только при выполнении команд hermes cron или во время активных сессий CLI.

Управляемый cron (Chronos) для scale-to-zero

Хостинговые шлюзы могут использовать провайдер Chronos (cron.provider: chronos) вместо встроенного тикера. Chronos позволяет простаивающему шлюзу масштабироваться до нуля и при этом продолжать запускать cron-задачи: вместо 60-секундного внутрипроцессного цикла (который держал бы процесс постоянно активным) он просит инфраструктуру Nous взвести ровно один управляемый одноразовый триггер на задачу, в момент её реального следующего срабатывания. В момент срабатывания Nous обращается обратно к шлюзу через аутентифицированный вебхук (POST /api/cron/fire); шлюз выполняет задачу тем же путём run_one_job, что и встроенный планировщик, а затем повторно взводит следующий одноразовый триггер. Между срабатываниями процесс может быть полностью остановлен — он просыпается только на реальное срабатывание, никогда по периодическому таймеру.

Поток данных (управляемый планировщик предоставляется Nous; агент не хранит учётные данные планировщика):

создание/обновление cron-задачи
  → Chronos просит Nous взвести одноразовый триггер на next_run_at задачи
      (аутентификация существующим токеном Nous агента)
  → в момент срабатывания Nous вызывает шлюз: POST {callback_url}/api/cron/fire
      (аутентификация краткоживущим JWT, выпущенным Nous под конкретную цель)
  → шлюз проверяет токен, забирает задачу (compare-and-set в хранилище, чтобы
    развёртывания с несколькими репликами срабатывали не более одного раза),
    выполняет её и повторно взводит следующий одноразовый триггер

Конфигурация (всё несекретное; на хостинговых агентах Nous задаёт эти значения при провижининге):

ключзначение
cron.providerchronos для активации (пусто = встроенный тикер)
cron.chronos.portal_urlбазовый URL Nous (взведение триггеров + издатель токенов срабатывания)
cron.chronos.callback_urlсобственный публичный базовый URL шлюза для входящих срабатываний
cron.chronos.expected_audienceaudience токена срабатывания данного агента
cron.chronos.nas_jwks_urlнабор ключей для проверки входящего токена срабатывания

Если Chronos настроен неверно или агент не залогинен в Nous, resolve_cron_scheduler() откатывается на встроенный тикер (с предупреждением в логе) — cron никогда не теряет триггер. Повторяющиеся задачи повторно взводятся после каждого срабатывания; задачи с repeat-N корректно останавливаются, когда счётчик исчерпан (без осиротевших одноразовых триггеров). Полный контракт взаимодействия агент↔Nous описан в контракте управляемого cron Chronos.

Изоляция свежей сессии

Каждая cron-задача выполняется в полностью новой сессии агента:

  • Никакой истории переписки из предыдущих запусков
  • Никакой памяти о прошлых выполнениях cron (постоянная память — MEMORY.md / USER.md — подгружается, как и в любом другом запуске агента, поэтому долгоживущие предпочтения и факты переносятся; контекст переписки конкретного запуска — нет)
  • Промпт должен быть самодостаточным — cron-задачи не могут задавать уточняющие вопросы
  • Набор инструментов cronjob отключён (защита от рекурсии)

Задачи на базе навыков

К cron-задаче можно прикрепить один или несколько навыков через поле skills. В момент выполнения:

  1. Навыки загружаются в указанном порядке
  2. Содержимое SKILL.md каждого навыка внедряется как контекст
  3. Промпт задачи добавляется как инструкция по задаче
  4. Агент обрабатывает объединённый контекст навыков + промпт

Это позволяет использовать переиспользуемые, проверенные сценарии работы, не вставляя полные инструкции в промпты cron. Например:

Create a daily funding report → attach "ai-funding-daily-report" skill

Задачи на базе скриптов

К задачам также можно прикрепить Python-скрипт через поле script. Скрипт запускается перед каждым ходом агента, а его stdout внедряется в промпт как контекст. Это позволяет реализовать паттерны сбора данных и обнаружения изменений:

# ~/.hermes/scripts/check_competitors.py
import requests, json
# Fetch competitor release notes, diff against last run
# Print summary to stdout — agent analyzes and reports

Таймаут скрипта по умолчанию — 3600 секунд (1 час). _get_script_timeout() определяет лимит через цепочку из трёх уровней:

  1. Переопределение на уровне модуля — _SCRIPT_TIMEOUT (для тестов/monkeypatching). Используется, только если отличается от значения по умолчанию.
  2. Переменная окружения — HERMES_CRON_SCRIPT_TIMEOUT
  3. Конфигурация — cron.script_timeout_seconds в config.yaml (читается через load_config())
  4. Значение по умолчанию — 3600 секунд (1 час)

Этот таймаут ограничивает только предзапускной скрипт, но не самого агента. Задачи на базе навыков / управляемые LLM работают по отдельному бюджету, основанному на простое (HERMES_CRON_TIMEOUT, по умолчанию 600 секунд простоя, 0 = без ограничения) — они могут выполняться часами, пока продолжают вызывать инструменты или стримить токены, и завершаются принудительно только после настроенного периода простоя без активности. На платформах, где монотонные часы Python не считают время сна (macOS и Linux), время, которое хост проспал, в этот период простоя не засчитывается; на Windows поведение не изменилось. Скрипты отправляются в постоянный пул потоков (не удерживаются под блокировкой тика), поэтому долго выполняющийся скрипт не блокирует срабатывание других подошедших задач.

При таймауте или отмене по владению cron.scheduler_script идёт общим путём жёсткого снятия agent.deadline.kill_process_tree. На POSIX он ненадолго останавливает и заново сканирует живое дерево, прежде чем послать сигнал потомкам и их родителю — включая детей в отдельных сессиях, не унаследовавших каналы вывода. Это закрывает гонку «форк уже после снимка». Ожидание остановки ограничено по времени; при сбоях обхода или нехватке прав дело сводится к уборке по группе «как получится», а не к гарантии песочницы. Любую цель, остановленную уборкой, возобновляют, если завершить её не удалось; уже остановленные цели сохраняют своё исходное состояние. Явные мягкие сигналы получателей не приостанавливают. На Windows по-прежнему работает taskkill /F /T.

Восстановление провайдера

run_job() передаёт настроенные пользователем резервные провайдеры и пул учётных данных в экземпляр AIAgent:

  • Резервные провайдеры — читает fallback_providers (список) или fallback_model (устаревший словарь) из config.yaml, повторяя паттерн _load_fallback_model() шлюза. Передаётся как fallback_model= в AIAgent.__init__, который нормализует оба формата в единую резервную цепочку. Только для незакреплённых задач: _job_fallback_chain() не возвращает цепочку для задачи с собственными provider, model или base_url, и тот же ответ используется при обходе разрешения учётных данных в _resolve_job_runtime(), при проверке ключа перед запуском и в лестнице фолбэков по ходу запуска, так что закреплённая задача никогда не попадает на запись глобальной цепочки (#100437). Функция hermes_cli.fallback_config.scoped_fallback_chain() общая с закреплёнными дочерними процессами делегирования.
  • Пул учётных данных — загружается через load_pool(provider) из agent.credential_pool с использованием имени провайдера, определённого во время выполнения. Передаётся, только если в пуле есть учётные данные (pool.has_credentials()). Обеспечивает ротацию ключей одного провайдера при ошибках 429/превышения лимита запросов.

Это повторяет поведение шлюза — без этого cron-агенты падали бы при превышении лимитов, даже не пытаясь восстановиться.

Модель доставки

Результаты cron-задачи можно доставить на любую поддерживаемую платформу.

Голое имя платформы (slack, telegram, …) доставляет в настроенный домашний канал этой платформы. Чтобы указать конкретный пункт назначения, добавьте цель через двоеточие: platform:<target>. Цель разрешается в момент срабатывания (а не в момент создания задачи), поэтому задача может указывать пункт назначения на платформе, которая ещё не подключена, и начнёт доставлять сообщения, как только та подключится.

Большинство платформ также принимают опциональный тред/топик третьим сегментом: platform:<chat_id>:<thread_id>.

ЦельСинтаксисПример
Исходный чатoriginДоставить в чат, где была создана задача
Локальный файлlocalСохранить в ~/.hermes/cron/output/
Telegramtelegram, telegram:<chat_id>, telegram:<chat_id>:<thread_id>, telegram:@usernametelegram:-1001234567890:17585
Discorddiscord, discord:#channel, discord:<channel_id>, discord:<channel_id>:<thread_id>discord:#engineering
Slackslack, slack:#channel, slack:<channel_id>, slack:<channel_id>:<thread_ts>slack:#engineering
Matrixmatrix, matrix:<!room_id:server>, matrix:<@user:server>matrix:!abc123:example.org
Feishufeishu, feishu:<chat_id>, feishu:<chat_id>:<thread_id>feishu:oc_abc123def
WhatsAppwhatsapp, whatsapp:<jid>, whatsapp:+<E.164>whatsapp:123456@g.us
Signalsignal, signal:group:<id>, signal:+<E.164>signal:group:aBcD==
SMSsms, sms:+<E.164>sms:+<E.164 number>
Emailemail, email:<address>email:alerts@example.com
Weixinweixin, weixin:<wxid>weixin:wxid_abc123
Mattermostmattermost или mattermost:<channel_id>Голое имя доставляет в домашний канал Mattermost
Home Assistant (плагин)homeassistant или homeassistant:<notify target>Голое имя доставляет в HASS_HOME_CHANNEL; нужен плагин каталога homeassistant
DingTalkdingtalk или dingtalk:<chat_id>Голое имя доставляет в DingTalk
WeComwecom или wecom:<chat_id>Голое имя доставляет в WeCom
BlueBubblesbluebubbles или bluebubbles:<chat_guid>Голое имя доставляет в iMessage через BlueBubbles
QQ Botqqbot или qqbot:<chat_id>Голое имя доставляет в QQ (Tencent) через Official API v2
Bot Chatbot-chat или bot-chat:<profile>Подать вывод в канонический Bot Chat локального профиля (бот отвечает)

Платформы из первой группы имеют явный, проверяемый синтаксис цели — именованные каналы (#channel), топики/треды, ID комнат/пользователей, ID групп или номера телефонов. Остальные платформы принимают универсальную форму platform:<chat_id> (значение после двоеточия используется как есть в качестве ID пункта назначения); голое имя платформы всегда доставляет в домашний канал.

Именованные каналы (slack:#engineering, discord:#engineering или дружественное имя вроде slack:engineering) разрешаются по справочнику каналов, который шлюз строит из подключённых адаптеров, поэтому для успешного разрешения имени шлюз должен уже обнаружить этот канал; сырые ID (slack:C0123ABCD45) работают всегда.

Для топиков Telegram используйте telegram:<chat_id>:<thread_id> (например, telegram:-1001234567890:17585). Для тредов Slack третий сегмент — это thread_ts родительского сообщения (например, slack:C0123ABCD45:1700000000.000100), поэтому он применим только при ответе внутри существующего сообщения.

Bot Chat (bot-chat, bot-chat:<profile>) — машинно-локальная псевдоплатформа, а не адаптер шлюза. Канонический живой владелец с почтовым ящиком получает устойчивый приём сразу — занят он или простаивает; входящий ход выполняет только этот владелец. scheduler_delivery._deliver_to_bot_chat разрешает цель через get_profile_dir или текущий get_hermes_home задачи, выводит ID квитанции из исходного home, ID задачи, устойчивого execution_id и целевого home, и сверяет квитанцию, прежде чем искать владельца. Существующая квитанция откат на CLI не разрешает никогда. Если владельца с ящиком нет, остаётся прежний путь hermes [-p <profile>] chat --in ~ -c "Bot Chat" --create-if-missing -Q --query-file <tmp> и обычное ограждение по владению. Обе дорожки доставляют настоящий входящий ход, а не зеркало транскрипта. Квитанции в состоянии queued/claimed заполняют last_delivery_queued своими идентификаторами. Агрегатор доставки не принимает уведомления о приёме за настоящие ошибки и записывает у запуска delivery_outcome=queued; успешные задачи получают last_status=delivery_queued. Настоящие ошибки на смешанных целях перевешивают и дают статус «провал», сохраняя при этом метаданные queued-квитанции. Для терминального завершения авторитетна устойчивая квитанция целевого профиля. Queued — это исторический итог приёма, а не доказательство доставки. Исторический статус cron за последующим завершением квитанции автоматически не следит. Цели bot-chat исключены из all и из предварительной проверки учётных данных. Внешние воркеры, у которых только цели bot-chat, минуют очередь доставки шлюза; при смешанных целях передача через шлюз сохраняется. cron.bot_chat_delivery_timeout_seconds (по умолчанию 600) ограничивает только старую подпроцессную дорожку.

Оборачивание ответа

По умолчанию (cron.wrap_response: true) доставки cron оборачиваются:

  • Заголовком, указывающим имя cron-задачи и её задачу
  • Подвалом, отмечающим, что агент не видит доставленное сообщение в переписке

Префикс [SILENT] в ответе cron полностью подавляет доставку — это полезно для задач, которым нужно только записать в файлы или выполнить побочные эффекты.

Изоляция сессии

Доставки cron НЕ зеркалируются в историю переписки сессии шлюза. Они существуют только в собственной сессии cron-задачи. Это предотвращает нарушения чередования сообщений в переписке целевого чата.

Защита от рекурсии

У сессий, запущенных cron, отключён набор инструментов cronjob. Это предотвращает:

  • Создание новых cron-задач запланированной задачей
  • Рекурсивное планирование, способное взорвать расход токенов
  • Случайное изменение расписания задачи изнутри самой задачи

Блокировки

Планировщик использует межпроцессную файловую блокировку (fcntl.flock в Unix, msvcrt.locking в Windows), чтобы не дать перекрывающимся тикам дважды выполнить один и тот же пакет подошедших задач — даже между внутрипроцессным тикером шлюза и отдельным вызовом hermes cron / ручным tick(). Если блокировку захватить не удаётся, tick() немедленно возвращает 0.

Уступка из-за устаревшего кода

Перед блокировкой тика шлюз, чей checkout обновили у него из-под ног (ревизия при загрузке ≠ ревизия на диске), уступает тик, если блокировку среды выполнения шлюза держит другой процесс: задачи запускает тикер более свежего шлюза, а устаревший не должен соревноваться с ним со смешанными sys.modules. Уступка поднимается как исключение (CronTickYielded) и сохраняется как последняя ошибка тикера, поэтому hermes cron status сообщает «Gateway is running STALE code — its cron ticker yields every tick and fires NOTHING» с обеими ревизиями и командой перезапуска, хотя пульс живости продолжает обновляться. Цикл замыкает hermes update: шлюз, устаревание которого доказала матрица версий парка после обновления, передаётся на путь request_restart со сливом (SIGUSR1), а не остаётся работать; шлюз под супервизором перезапускается на новом коде, а голый gateway run останавливается и попадает в список «Restart manually».

Интерфейс CLI

CLI hermes cron предоставляет прямое управление задачами:

hermes cron list                    # Показать все задачи
hermes cron create                  # Интерактивное создание задачи (алиас: add)
hermes cron edit <job_id>           # Редактировать конфигурацию задачи
hermes cron pause <job_id>          # Приостановить выполняющуюся задачу
hermes cron resume <job_id>         # Возобновить приостановленную задачу
hermes cron run <job_id>            # Запустить немедленное выполнение
hermes cron remove <job_id>         # Удалить задачу

Связанные документы

перевод и редактура:

обновлено

⌕ ESC