Chronos 托管 cron——agent ↔ NAS 线上契约

状态: Chronos cron provider 的权威线上规范。 读者: agent-cron 端点(nous-account-service)的 NAS 侧实现者,以及所有调试托管 cron 路径的人。

Chronos 让托管的 Hermes gateway 在空闲时缩容到零,仍能触发 cron 任务。agent 不再使用进程内 60 秒 ticker,而是请 NAS 在每个任务真正的下次触发时刻,精确挂载一个外部一次性定时器。NAS 在触发时刻经已认证的 webhook 回调 agent;agent 运行该任务并重新挂载下一个一次性定时器。两次触发之间,agent 进程可以完全停掉——它只在真正触发时醒来。

NAS 用来实现这些一次性定时器的外部调度器是NAS 的内部实现细节。agent 从不与之通信、从不持有其凭据、也从不点名它。agent 只知道下面三个 NAS 端点。

创建/更新/暂停/恢复/删除一个 cron 任务(agent 侧)
  │
  ▼
ChronosCronScheduler.reconcile()        ── agent 计算 next_run_at
  │  POST {portal}/api/agent-cron/provision   (认证:agent 的 Nous access token)
  ▼
NAS 为 fire_at 挂载一个一次性定时器      ── NAS 拥有调度器及其凭据
  │
  ⏰ 到 fire_at
  ▼
scheduler → POST {portal}/api/agent-cron/relay   (认证:调度器签名,NAS 校验)
  │
  ▼
NAS 签发一个短命的 agent-audience JWT(purpose=cron_fire)
  │  POST {agent_callback_url}/api/cron/fire        (认证:该 JWT)
  ▼
agent 校验 NAS JWT → 存 CAS claim → run_one_job → 重新挂载下一个一次性定时器

信任模型(先读这一节)

跳谁调用谁认证机制校验方
1agent → NAS(provision/cancel/list)agent 现有的 Nous Portal access token(Bearer)——对托管 agent 而言这是 NAS 种在 auth.json 里的bootstrap-session token(client hermes-cli-vps),不是 agent:* client tokenNAS(其常规 agent-token 路径)
2scheduler → NAS(relay)调度器请求签名NAS(它已有的签名路径)
3NAS → agent(/api/cron/fire)一个短命的 NAS 签发 JWT(aud=agent:{instance_id},purpose=cron_fire)agent(用 PyJWT 对照 NAS JWKS)

到底用哪个 token(跳 1)。 托管 agent 从不持有 agent:{instance_id} OAuth client 凭据——那种形态只由交互式 dashboard auth-code 授权(浏览器用户)签发。agent 自身所有出站 portal 调用都用 bootstrap-session access token(resolve_nous_access_token), 它在仅 bootstrap 的 client hermes-cli-vps 下签发,并在首次启动时种入 容器。因此 NAS 必须从 agent:{id} client(自托管/dashboard 调用方)解析调用 agent 的实例 id,或者——对 bootstrap token——从 AgentInstance.bootstrapSessionId 匹配 token 的 session id(sid)来解析,且按 org 隔离。跳 3 签发的触发 JWT 仍然携带 aud=agent:{instance_id}。(若仅以 agent:* client 把守跳 1,会让每一个真实托管 agent 的 provision 都 403——见 src/server/agent-cron/instance-auth.ts。)

为什么是经 NAS 中转、而不是调度器直连 agent:调度器用NAS 的密钥签名,而 agent 不(也不应)持有这些密钥。agent 只能校验NAS 签发的 token——这是它已有的信任路径。这把所有调度器凭据都留在 NAS 内部。(完整理由:计划的 DQ-4。)

agent 侧不引入任何新机密:跳 1 复用 agent 已用于 portal 的 token,跳 3 复用 agent 已做的 NAS-JWT 校验。


端点 1——POST /api/agent-cron/provision (agent → NAS)

为一个任务挂载(或幂等地重新挂载)恰好一个一次性定时器。

  • 认证: Authorization: Bearer <agent Nous access token>。NAS 经其常规 agent-token 路径校验,并把该行按调用 agent/org 划定 scope。
  • 请求体:
    {
      "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,由 agent 计算。未来可能精确到亚分钟;NAS 必须支持秒级精度(时间归 agent 所有,因此不存在 1 分钟的调度器下限)。
    • agent_callback_url——agent 自己的公网可达 base URL。NAS 在触发时刻 POST {agent_callback_url}/api/cron/fire。
    • dedup_key——"{job_id}:{fire_at}"。NAS 按 (agent_id, job_id) upsert,因此为同一触发重新挂载是幂等的(不会产生重复一次性定时器)。同一 job_id 的新 fire_at 会替换之前的挂载。
  • 动作: 挂载一个一次性定时器于 fire_at 触发,目标指向 NAS 的 relay 路由(端点 3)——不直连 agent,以便 NAS 留在回路中签发 agent JWT。持久化 (agent_id, job_id, schedule_id, agent_callback_url)。
  • 响应: 200 {"schedule_id": "<不透明>"}。

端点 2——POST /api/agent-cron/cancel (agent → NAS)

  • 认证: 同端点 1。
  • 请求体: {"job_id": "ab12cd34"}。
  • 动作: 取消为 (agent_id, job_id) 挂载的一次性定时器并删除该行。幂等——取消一个未知任务是 200 no-op。
  • 响应: 200 {"ok": true}。

端点 3——POST /api/agent-cron/relay (scheduler → NAS,触发中转)

  • 认证: 调度器请求签名,由 NAS 用它已有的签名路径校验。这是触发的信任边界——伪造的 relay 调用必须在这里被拒。
  • 动作:
    1. 从持久化行查 (agent_id, job_id) → agent_callback_url。
    2. 签发一个短命 JWT:aud = "agent:{instance_id}"、iss = {portal_url}、purpose = "cron_fire"、很短的 exp(约 60–120s),用 NAS 常规非对称签名密钥(经 JWKS 发布)签名。
    3. POST {agent_callback_url}/api/cron/fire,带 Authorization: Bearer <该 JWT>,请求体 {"job_id": "...", "fire_at": "..."}。
    4. 把 agent 的非 2xx 响应当作可重试失败(让调度器重试 relay)。agent 的存储 CAS 会对双重触发去重,因此重试是安全的。
  • 对调度器的响应: agent 的 POST 被接受(202)后即返回 2xx,以便调度器不会对已送达的触发重试。

入站 POST /api/cron/fire (NAS → agent)——agent 侧,已实现

这是 NAS 在端点 3 第 3 步调用的 agent 端点。托管部署上有两跳:

  1. Dashboard 应用(hermes_cli/web_server.py)——agent 唯一的公网 HTTP 面(Fly 代理恰好暴露一个端口,即 dashboard 的)。它在 PUBLIC_API_PATHS 中,因此 dashboard 的 cookie 网关会放行 bearer-JWT 回调直达校验器。dashboard 校验 JWT、解析任务的 profile,然后转发触发到回环上的第 2 跳,保留 NAS bearer——它不自己执行任务。
  2. Gateway APIServerAdapter(gateway/platforms/api_server.py,回环绑定,默认端口 8642)——再次校验 JWT(深度防御),并以 gateway 的活跃平台适配器运行任务,这正是让中继前置逻辑平台与 E2EE 房间也能投递的关键(独立 send 路径两者都服务不了)。自托管且直接暴露 api_server 的部署不经第 1 跳,直接命中第 2 跳。

从第 1 跳触达不到 gateway(缩容到零唤醒仍在启动、重启窗口、api_server 被禁用)→ dashboard 返回 503,NAS 重试(非 2xx = 可重试,见下);存储 CAS 对最终的双重触发去重。刻意不做 dashboard 内执行回退。校验器是 plugins/cron/chronos/verify.py。

  • 认证: Authorization: Bearer <NAS 签发 JWT>。agent 校验:
    • 对照 NAS JWKS(cron.chronos.nas_jwks_url)验签,
    • aud == cron.chronos.expected_audience(本 agent 的 agent:{instance_id}),
    • iss == cron.chronos.portal_url,
    • exp / nbf(30s 容差),
    • purpose == "cron_fire"——通用 agent JWT(无/其他 purpose)会被拒,以防被重放到此端点。
  • 请求体: {"job_id": "ab12cd34", "fire_at": "..."}(只用 job_id)。
  • 行为:
    • 无效/缺失/伪造/过期/aud 不符/purpose 不符的 token → 401,不执行。
    • 缺 job_id → 400。
    • 有效 → 立即 202 {"status": "accepted", "job_id": "..."},任务在后台运行。先 202 后执行,意味着一轮很长的 agent 对话绝不会触发 relay 的 HTTP 超时。
  • 至多一次: agent 在运行前先用存储层 compare-and-set(claim_job_for_fire)认领任务。第一次触发在途(或已完成)时到达的 relay/调度器重试会输掉认领,不会重复运行。

至多一次与重新挂载语义

  • 周期性(cron/间隔): 触发时,agent(在其存储锁下)把 next_run_at 前移作为认领的一部分,运行任务,然后为新的 next_run_at 重新 provision 一个一次性定时器。针对旧 fire_at 的重复 relay 会发现认领已被占/时间已前移而被丢弃。
  • 一次性(30m、+90s 等): 触发一次;mark_job_run 把它标记为完成。不重新挂载。
  • repeat.times = N: mark_job_run 在到达上限时删除任务,因此最后一次触发后 get_job 返回 None → agent 不重新挂载 → 调度干净停止,没有孤儿一次性定时器。
  • 多副本 agent: 存储 CAS 让共享一个 HERMES_HOME 的 N 个 gateway 副本上的触发至多一次——恰好一个副本运行每次触发。

Reconcile(自愈)

agent 在以下时机 reconcile 期望态(jobs.json)与已挂载态:

  • start()(gateway 启动 / 唤醒),
  • 每次成功的任务变更(on_jobs_changed),
  • 每次触发后顺带进行(重新挂载)。

Reconcile 挂载缺失/时间已变的任务,取消孤儿。一次丢失的 provision(瞬时 NAS 错误)会在下次 reconcile 自愈。没有对休眠 agent 的周期性唤醒——那会抵消缩容到零。

配置(agent 侧)

全部为非机密(config.yaml 中的 cron.chronos.*);agent 不持有调度器凭据。对托管 agent,NAS 在 provision 时设置这些:

键含义
cron.provider"chronos" 以激活(留空 = 内置 ticker)
cron.chronos.portal_urlNAS base URL(也是期望的 JWT iss)
cron.chronos.callback_urlagent 自己的公网 base URL,供 NAS→agent 触发
cron.chronos.expected_audience本 agent 的 JWT aud(agent:{instance_id})
cron.chronos.nas_jwks_url用于校验触发 JWT 的 NAS JWKS

若 callback_url / portal_url 为空,或 agent 没有 Nous 登录,is_available() 返回 False,解析器回退到内置进程内 ticker——cron 永不失去触发器。

运行时身份拒绝(403 invalid_client)。 is_available() 只看配置,因此它无法判断存储的 Nous token 是否就是 NAS 映射到已 provision 实例的那个身份(上面的跳 1)。当 provision 回 403 invalid_client 时——auth.json 里的 token 是普通 hermes-cli 用户登录,而非 hermes-cli-vps bootstrap session 或 agent:* client——该凭据整个生命周期内的拒绝都是确定性的:每次挂载、重挂、list 都会同样失败,而一次 hermes auth 重新登录会让它永久化(它替换了 bootstrap session;只有 NAS 能重新签发一个)。因此 provider 只记录一条指明该补救办法的告警,停止调用 NAS,并在进程剩余生命周期内启动内置 ticker,让任务按时触发,而不是只靠迟到的 misfire 扫尾(cron.misfire_grace_minutes)。瞬时失败(5xx、传输)不降级;下次 reconcile 会重试。

逃生舱(非默认)

入站 /api/cron/fire 校验器是可插拔的(get_fire_verifier())。若经 NAS 中转的 relay 流量将来饱和,可以用带每任务 NAS 签发 cron-key 的直连 scheduler→agent 模式替换 NAS-JWT 校验器,而无需改动 webhook 处理器。经 NAS 中转(本契约)是默认。