Управляемый 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 (уже имеющийся у него путь проверки подписи)
3NAS → агент (/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-вызов ДОЛЖЕН быть отклонён именно здесь.
  • Действие:
    1. Найти (agent_id, job_id) → agent_callback_url в сохранённой записи.
    2. Выпустить краткоживущий JWT: aud = "agent:{instance_id}", iss = {portal_url}, purpose = "cron_fire", небольшой exp (≈60–120 с), подписанный обычным асимметричным ключом подписи NAS (опубликован через JWKS).
    3. POST {agent_callback_url}/api/cron/fire с Authorization: Bearer <that JWT> и телом {"job_id": "...", "fire_at": "..."}.
    4. Ответ агента не из диапазона 2xx считать повторяемой ошибкой (пусть планировщик повторит relay). CAS в хранилище агента отсеивает двойное срабатывание, так что повторы безопасны.
  • Ответ планировщику: 2xx, как только POST к агенту принят (202), чтобы планировщик не повторял уже доставленное срабатывание.

Входящий POST /api/cron/fire (NAS → агент) — сторона агента, уже реализовано

Это эндпоинт агента, который NAS вызывает на шаге 3 эндпоинта 3. На хостинговых развёртываниях — два перехода:

  1. Приложение дашборда (hermes_cli/web_server.py) — единственная публичная HTTP-поверхность агента (прокси Fly открывает ровно один порт — порт дашборда). Путь входит в PUBLIC_API_PATHS, поэтому cookie-заслон дашборда пропускает колбэк с bearer-JWT до верификатора. Дашборд проверяет JWT, определяет профиль задачи и пересылает срабатывание на переход 2 через loopback, сохраняя bearer NAS, — сам он задачу НЕ выполняет.
  2. 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 или с другим) отклоняется, чтобы его нельзя было воспроизвести против этого эндпоинта.
  • Тело: {"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_audienceaud JWT этого агента (agent:{instance_id})
cron.chronos.nas_jwks_urlJWKS 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 (этот контракт) — режим по умолчанию.

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

обновлено

⌕ ESC