Управляемый cron Chronos — сетевой контракт агент ↔ NAS
Статус: авторитетная спецификация протокола для cron-провайдера Chronos.
Аудитория: реализатор эндпоинтов agent-cron на стороне NAS
(nous-account-service) и все, кто отлаживает путь управляемого cron.
Chronos даёт хостинговому шлюзу Hermes масштабироваться до нуля в простое и при этом запускать задачи cron. Вместо внутрипроцессного 60-секундного тикера агент просит NAS взвести ровно один внешний одноразовый триггер на задачу, на реальное время её следующего срабатывания. В момент срабатывания NAS вызывает агента обратно через аутентифицированный вебхук; агент выполняет задачу и заново взводит следующий одноразовый триггер. Между срабатываниями процесс агента может быть полностью остановлен — он просыпается только на настоящее срабатывание.
Внешний планировщик, которым NAS реализует одноразовые триггеры, — внутренняя деталь реализации NAS. Агент никогда не общается с ним, не хранит его учётные данные и не называет его. Агент знает только три эндпоинта NAS, описанные ниже.
create/update/pause/resume/remove a cron job (agent side)
│
▼
ChronosCronScheduler.reconcile() ── agent computes next_run_at
│ POST {portal}/api/agent-cron/provision (auth: agent's Nous access token)
▼
NAS arms a one-shot for fire_at ── NAS owns the scheduler + its creds
│
⏰ at fire_at
▼
scheduler → POST {portal}/api/agent-cron/relay (auth: scheduler signature, NAS-verified)
│
▼
NAS mints a short-lived agent-audience JWT (purpose=cron_fire)
│ POST {agent_callback_url}/api/cron/fire (auth: that JWT)
▼
agent verifies the NAS JWT → store CAS claim → run_one_job → re-arm next one-shot
Модель доверия (сначала прочитайте это)
| Переход | Кто кого вызывает | Механизм аутентификации | Кто проверяет |
|---|---|---|---|
| 1 | агент → NAS (provision/cancel/list) | существующий токен доступа Nous Portal агента (Bearer) — для хостингового агента это токен bootstrap-сессии, который NAS положил в auth.json (клиент hermes-cli-vps), а НЕ клиентский токен agent:* | NAS (его обычный путь проверки токенов агента) |
| 2 | планировщик → NAS (relay) | подпись запроса планировщика | NAS (уже имеющийся у него путь проверки подписи) |
| 3 | NAS → агент (/api/cron/fire) | краткоживущий JWT, выпущенный NAS (aud=agent:{instance_id}, purpose=cron_fire) | агент (PyJWT по JWKS NAS) |
Какой именно токен (переход 1). Хостинговый агент никогда не держит OAuth client credential вида
agent:{instance_id}— такую форму выпускает только интерактивный auth-code grant дашборда (пользователь в браузере). Для всех своих исходящих вызовов к порталу агент использует токен доступа bootstrap-сессии (resolve_nous_access_token), выпущенный под bootstrap-only-клиентhermes-cli-vpsи заложенный в контейнер при первом запуске. Поэтому NAS ДОЛЖЕН определять instance id вызывающего агента ЛИБО из клиентаagent:{id}(self-hosted-вызовы и вызовы из дашборда), ЛИБО — для bootstrap-токена — поAgentInstance.bootstrapSessionId, совпадающему с id сессии токена (sid), в пределах организации. JWT срабатывания, выпущенный на переходе 3, в любом случае несётaud=agent:{instance_id}. (Если пропускать переход 1 только по клиентуagent:*, каждый реальный provision хостингового агента получит 403 — см.src/server/agent-cron/instance-auth.ts.)
Почему через NAS, а не напрямую планировщик → агент: планировщик подписывает ключами NAS, которых у агента нет (и быть не должно). Агент умеет проверять только токен, выпущенный NAS, — путь доверия, который у него уже есть. Так все учётные данные планировщика остаются внутри NAS. (Полное обоснование — DQ-4 в плане.)
Никакого нового секрета на агенте не появляется: переход 1 переиспользует токен, которым агент уже ходит в портал, а переход 3 — проверку JWT NAS, которую агент уже выполняет.
Эндпоинт 1 — POST /api/agent-cron/provision (агент → NAS)
Взводит (или идемпотентно перевзводит) ровно один одноразовый триггер для задачи.
- Аутентификация:
Authorization: Bearer <agent Nous access token>. NAS проверяет его своим обычным путём для токенов агента и привязывает запись к вызывающему агенту/организации. - Тело запроса:
{ "job_id": "ab12cd34", "fire_at": "2026-06-18T12:34:56+00:00", "agent_callback_url": "https://agent-xyz.fly.dev", "dedup_key": "ab12cd34:2026-06-18T12:34:56+00:00" }fire_at— ISO 8601, вычисляется агентом. Может быть меньше минуты в будущем; NAS ДОЛЖЕН соблюдать точность до секунды (временем владеет агент, поэтому минутного пола планировщика нет).agent_callback_url— собственный публично доступный базовый URL агента. В момент срабатывания NAS делает POST на{agent_callback_url}/api/cron/fire.dedup_key—"{job_id}:{fire_at}". NAS делает upsert по(agent_id, job_id), так что повторное взведение того же срабатывания идемпотентно (дубликатов одноразовых триггеров не возникает). Новыйfire_atдля того жеjob_idзаменяет прежнее взведение.
- Действие: взвести один одноразовый триггер на
fire_at, направленный на маршрут relay NAS (эндпоинт 3) — НЕ на агента напрямую, чтобы NAS оставался в цепочке и выпускал JWT для агента. Сохранить(agent_id, job_id, schedule_id, agent_callback_url). - Ответ:
200 {"schedule_id": "<opaque>"}.
Эндпоинт 2 — POST /api/agent-cron/cancel (агент → NAS)
- Аутентификация: как у эндпоинта 1.
- Тело:
{"job_id": "ab12cd34"}. - Действие: отменить взведённый одноразовый триггер для
(agent_id, job_id)и удалить запись. Идемпотентно — отмена неизвестной задачи возвращает 200 и ничего не делает. - Ответ:
200 {"ok": true}.
Эндпоинт 3 — POST /api/agent-cron/relay (планировщик → NAS, relay срабатывания)
- Аутентификация: подпись запроса планировщика, которую NAS проверяет уже имеющимся у него путём проверки подписи. Это граница доверия для срабатывания — поддельный relay-вызов ДОЛЖЕН быть отклонён именно здесь.
- Действие:
- Найти
(agent_id, job_id) → agent_callback_urlв сохранённой записи. - Выпустить краткоживущий JWT:
aud = "agent:{instance_id}",iss = {portal_url},purpose = "cron_fire", небольшойexp(≈60–120 с), подписанный обычным асимметричным ключом подписи NAS (опубликован через JWKS). POST {agent_callback_url}/api/cron/fireсAuthorization: Bearer <that JWT>и телом{"job_id": "...", "fire_at": "..."}.- Ответ агента не из диапазона 2xx считать повторяемой ошибкой (пусть планировщик повторит relay). CAS в хранилище агента отсеивает двойное срабатывание, так что повторы безопасны.
- Найти
- Ответ планировщику: 2xx, как только POST к агенту принят (202), чтобы планировщик не повторял уже доставленное срабатывание.
Входящий POST /api/cron/fire (NAS → агент) — сторона агента, уже реализовано
Это эндпоинт агента, который NAS вызывает на шаге 3 эндпоинта 3. На хостинговых развёртываниях — два перехода:
- Приложение дашборда (
hermes_cli/web_server.py) — единственная публичная HTTP-поверхность агента (прокси Fly открывает ровно один порт — порт дашборда). Путь входит вPUBLIC_API_PATHS, поэтому cookie-заслон дашборда пропускает колбэк с bearer-JWT до верификатора. Дашборд проверяет JWT, определяет профиль задачи и пересылает срабатывание на переход 2 через loopback, сохраняя bearer NAS, — сам он задачу НЕ выполняет. APIServerAdapterшлюза (gateway/platforms/api_server.py, привязка к loopback, порт по умолчанию 8642) — повторно проверяет JWT (эшелонированная защита) и выполняет задачу через живые адаптеры платформ шлюза; именно это делает возможной доставку в логические платформы за relay и в E2EE-комнаты (автономный путь отправки не умеет ни того, ни другого). Self-hosted развёртывания с API-сервером, которые открывают api_server напрямую, попадают на переход 2 без перехода 1.
Если шлюз недоступен с перехода 1 (пробуждение после scale-to-zero ещё грузится,
окно перезапуска, api_server отключён) → дашборд возвращает 503, и NAS
повторяет попытку (не-2xx = повторяемая ошибка, см. ниже); CAS в хранилище
отсеивает итоговое двойное срабатывание. Резервного выполнения внутри дашборда
намеренно нет. Верификатор — plugins/cron/chronos/verify.py.
- Аутентификация:
Authorization: Bearer <NAS-minted JWT>. Агент проверяет:- подпись по JWKS NAS (
cron.chronos.nas_jwks_url), aud==cron.chronos.expected_audience(agent:{instance_id}этого агента),iss==cron.chronos.portal_url,exp/nbf(допуск 30 с),purpose == "cron_fire"— общий JWT агента (без purpose или с другим) отклоняется, чтобы его нельзя было воспроизвести против этого эндпоинта.
- подпись по JWKS NAS (
- Тело:
{"job_id": "ab12cd34", "fire_at": "..."}(используется толькоjob_id). - Поведение:
- невалидный/отсутствующий/поддельный/просроченный токен, неверный aud или purpose → 401, без выполнения.
- отсутствует
job_id→ 400. - шлюз ещё запускается (пробуждение после масштабирования до нуля) → срабатывание
ждёт до 7 с, считая от получения, пока шлюз закончит запуск. Если он не успел
или тем временем началось дренирование → 503
{"error": "gateway unreachable; retry"}сRetry-After: 60, задача не захвачена (NAS повторит попытку). - валидный → сразу 202
{"status": "accepted", "job_id": "..."}, задача выполняется в фоне. 202 до выполнения означает, что длинный ход агента никогда не упрётся в HTTP-таймаут relay.
- Не более одного раза: перед выполнением агент забирает задачу через
compare-and-set на уровне хранилища (
claim_job_for_fire). Повтор от relay/планировщика, пришедший, пока первое срабатывание ещё выполняется (или уже завершилось), проигрывает борьбу за захват и не запускает задачу второй раз.
Семантика «не более одного раза» и повторного взведения
- Повторяющиеся (cron/interval): при срабатывании агент сдвигает
next_run_at(под блокировкой хранилища) как часть захвата, выполняет задачу и затем заново провизионирует одноразовый триггер на новоеnext_run_at. Дублирующий relay для старогоfire_atобнаруживает, что захват уже взят / время сдвинуто, и отбрасывается. - Одноразовые (
30m,+90sи т. п.): срабатывают один раз;mark_job_runпомечает задачу завершённой. Повторного взведения нет. repeat.times = N:mark_job_runудаляет задачу по достижении лимита, так что после последнего срабатыванияget_jobвозвращаетNone→ агент не перевзводит триггер → расписание останавливается чисто, без осиротевшего одноразового триггера.- Агенты с несколькими репликами: CAS в хранилище делает срабатывание
«не более одного раза» на N реплик шлюза, разделяющих один
HERMES_HOME, — каждое срабатывание выполняет ровно одна реплика.
Сверка (самовосстановление)
Агент сверяет желаемое (jobs.json) с взведённым:
- в
start()(загрузка / пробуждение шлюза), - при каждом успешном изменении задачи (
on_jobs_changed), - попутно после каждого срабатывания (повторное взведение).
Сверка взводит отсутствующие задачи и задачи с изменённым временем и отменяет осиротевшие. Пропущенный provision (временная ошибка NAS) самовосстанавливается на следующей сверке. Периодического пробуждения спящего агента нет — оно свело бы на нет scale-to-zero.
Конфигурация (сторона агента)
Всё несекретное (cron.chronos.* в config.yaml); агент не хранит учётных данных
планировщика. Для хостинговых агентов NAS задаёт эти значения при провижининге:
| ключ | значение |
|---|---|
cron.provider | "chronos" для активации (пусто = встроенный тикер) |
cron.chronos.portal_url | базовый URL NAS (он же ожидаемый iss JWT) |
cron.chronos.callback_url | собственный публичный базовый URL агента для срабатываний NAS→агент |
cron.chronos.expected_audience | aud JWT этого агента (agent:{instance_id}) |
cron.chronos.nas_jwks_url | JWKS NAS для проверки JWT срабатывания |
Если callback_url / portal_url пуст или агент не залогинен в Nous,
is_available() возвращает False, и резолвер откатывается на встроенный
внутрипроцессный тикер — cron никогда не теряет триггер.
Отказ по идентичности во время работы (403 invalid_client). is_available()
смотрит только на конфиг, поэтому не может понять, является ли сохранённый токен Nous
той идентичностью, которую NAS сопоставляет с провиженным инстансом (переход 1 выше).
Когда provision отвечает 403 invalid_client (в auth.json лежит обычный
пользовательский логин hermes-cli, а не bootstrap-сессия hermes-cli-vps и не
клиент agent:*), отказ детерминирован на всё время жизни этих учётных данных:
каждое взведение, повторное взведение и list упадут так же, а повторный вход через
hermes auth делает это необратимым (он заменяет bootstrap-сессию; выпустить новую
может только NAS). Поэтому провайдер пишет ОДНО предупреждение с указанием этого
способа исправления, перестаёт обращаться к NAS и до конца жизни процесса запускает
встроенный тикер. Так задачи продолжают срабатывать вовремя, а не только через
запоздалый проход по пропущенным срабатываниям (cron.misfire_grace_minutes).
Временные сбои (5xx, транспорт) к деградации не приводят: следующая сверка повторит
попытку.
Запасной выход (не по умолчанию)
Верификатор входящего /api/cron/fire подключаемый (get_fire_verifier()). Если
объём relay через NAS когда-нибудь упрётся в потолок, прямой режим планировщик →
агент с cron-ключом на задачу, выпускаемым NAS, сможет заменить верификатор JWT
NAS без изменений в обработчике вебхука. Режим через NAS (этот контракт) —
режим по умолчанию.