Внутреннее устройство 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):
- Сдвиг до диспетчеризации — предварительный.
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(правка, пауза, возобновление, запуск сейчас) его сбрасывает. - Восстановить один раз. Более позднее сканирование, нашедшее
pending_slot, чей владелец доказуемо исчез (этот процесс, а задачи нет в его наборе выполняющихся; другой процесс, у которого истекла 300-секундная аренда заявки на срабатывание или мёртвый pid), возвращаетscheduled_atобратно вnext_run_at, снимает штамп и пишет WARNING (cron/occurrences.py::unclaimed_pending_slot). Так происходит не больше одного раза на потерянное срабатывание — восстановленный момент дальше подчиняется обычным правилам ниже, как любой другой просроченный слот, так что повтора N слотов не бывает. - Уже сработало → никогда дважды.
completed_occurrence()сверяется с журналом выполнений на предмет строкиcompletedс точно такимscheduled_instant, прежде чем что-либо будет считаться подошедшим; слот, выполнившийся до перезапуска, сдвигается без срабатывания. Строкиfailed/unknownзавершением не считаются, как и строкаcompleted, чейfinished_at(иначеclaimed_at) раньше момента, которым она помечена, — запуск не может подтвердить срабатывание, которого ещё не было. Строки без сопоставимой метки времени продолжают учитываться. Идентичность срабатывания можно заявить, только когда оно подошло:claim_job_for_fireотбрасываетscheduled_instant, который ещё в будущем, поэтому срабатывание вне тика (запуск из дашборда, вебхук, перехват аренды, страховка от пропусков) выполняется без привязки к срабатыванию, а не съедает следующий слот. - Опоздание в пределах отсрочки → сработать с опозданием. Отсрочка = половина периода,
ограниченная диапазоном
[120 с, 2 ч](_compute_grace_seconds); диспетчеризация помечаетсяlast_dispatch.kind = late. - Позже отсрочки → схлопнуть накопившееся, сработать один раз (
kind = catch_up) либо пропустить с записью причины в лог, если оператор задалcron.catch_up_missed: false(плановый простой). Одноразовые задачи, просрочившие свои 120 с отсрочки, снимаются с диагностикой и никогда не воскрешаются. - Приостановленные / отключённые / завершённые задачи никогда не срабатывают; сканирование
отбрасывает их до всего перечисленного, а пауза/возобновление снимает любой ожидающий слот.
Однако повторяющееся срабатывание, срок которого подошёл во время паузы, не теряется:
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.provider | chronos для активации (пусто = встроенный тикер) |
cron.chronos.portal_url | базовый URL Nous (взведение триггеров + издатель токенов срабатывания) |
cron.chronos.callback_url | собственный публичный базовый URL шлюза для входящих срабатываний |
cron.chronos.expected_audience | audience токена срабатывания данного агента |
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. В момент выполнения:
- Навыки загружаются в указанном порядке
- Содержимое SKILL.md каждого навыка внедряется как контекст
- Промпт задачи добавляется как инструкция по задаче
- Агент обрабатывает объединённый контекст навыков + промпт
Это позволяет использовать переиспользуемые, проверенные сценарии работы, не вставляя полные инструкции в промпты 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() определяет лимит через цепочку из трёх уровней:
- Переопределение на уровне модуля —
_SCRIPT_TIMEOUT(для тестов/monkeypatching). Используется, только если отличается от значения по умолчанию. - Переменная окружения —
HERMES_CRON_SCRIPT_TIMEOUT - Конфигурация —
cron.script_timeout_secondsвconfig.yaml(читается черезload_config()) - Значение по умолчанию — 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/ |
| Telegram | telegram, telegram:<chat_id>, telegram:<chat_id>:<thread_id>, telegram:@username | telegram:-1001234567890:17585 |
| Discord | discord, discord:#channel, discord:<channel_id>, discord:<channel_id>:<thread_id> | discord:#engineering |
| Slack | slack, slack:#channel, slack:<channel_id>, slack:<channel_id>:<thread_ts> | slack:#engineering |
| Matrix | matrix, matrix:<!room_id:server>, matrix:<@user:server> | matrix:!abc123:example.org |
| Feishu | feishu, feishu:<chat_id>, feishu:<chat_id>:<thread_id> | feishu:oc_abc123def |
whatsapp, whatsapp:<jid>, whatsapp:+<E.164> | whatsapp:123456@g.us | |
| Signal | signal, signal:group:<id>, signal:+<E.164> | signal:group:aBcD== |
| SMS | sms, sms:+<E.164> | sms:+<E.164 number> |
email, email:<address> | email:alerts@example.com | |
| Weixin | weixin, weixin:<wxid> | weixin:wxid_abc123 |
| Mattermost | mattermost или mattermost:<channel_id> | Голое имя доставляет в домашний канал Mattermost |
| Home Assistant (плагин) | homeassistant или homeassistant:<notify target> | Голое имя доставляет в HASS_HOME_CHANNEL; нужен плагин каталога homeassistant |
| DingTalk | dingtalk или dingtalk:<chat_id> | Голое имя доставляет в DingTalk |
| WeCom | wecom или wecom:<chat_id> | Голое имя доставляет в WeCom |
| BlueBubbles | bluebubbles или bluebubbles:<chat_guid> | Голое имя доставляет в iMessage через BlueBubbles |
| QQ Bot | qqbot или qqbot:<chat_id> | Голое имя доставляет в QQ (Tencent) через Official API v2 |
| Bot Chat | bot-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> # Удалить задачу