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 → 重新挂载下一个一次性定时器
信任模型(先读这一节)
| 跳 | 谁调用谁 | 认证机制 | 校验方 |
|---|---|---|---|
| 1 | agent → NAS(provision/cancel/list) | agent 现有的 Nous Portal access token(Bearer)——对托管 agent 而言这是 NAS 种在 auth.json 里的bootstrap-session token(client hermes-cli-vps),不是 agent:* client token | NAS(其常规 agent-token 路径) |
| 2 | scheduler → NAS(relay) | 调度器请求签名 | NAS(它已有的签名路径) |
| 3 | NAS → 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 的 clienthermes-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 调用必须在这里被拒。
- 动作:
- 从持久化行查
(agent_id, job_id) → agent_callback_url。 - 签发一个短命 JWT:
aud = "agent:{instance_id}"、iss = {portal_url}、purpose = "cron_fire"、很短的exp(约 60–120s),用 NAS 常规非对称签名密钥(经 JWKS 发布)签名。 POST {agent_callback_url}/api/cron/fire,带Authorization: Bearer <该 JWT>,请求体{"job_id": "...", "fire_at": "..."}。- 把 agent 的非 2xx 响应当作可重试失败(让调度器重试 relay)。agent 的存储 CAS 会对双重触发去重,因此重试是安全的。
- 从持久化行查
- 对调度器的响应: agent 的 POST 被接受(202)后即返回 2xx,以便调度器不会对已送达的触发重试。
入站 POST /api/cron/fire (NAS → agent)——agent 侧,已实现
这是 NAS 在端点 3 第 3 步调用的 agent 端点。托管部署上有两跳:
- Dashboard 应用(
hermes_cli/web_server.py)——agent 唯一的公网 HTTP 面(Fly 代理恰好暴露一个端口,即 dashboard 的)。它在PUBLIC_API_PATHS中,因此 dashboard 的 cookie 网关会放行 bearer-JWT 回调直达校验器。dashboard 校验 JWT、解析任务的 profile,然后转发触发到回环上的第 2 跳,保留 NAS bearer——它不自己执行任务。 - 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)会被拒,以防被重放到此端点。
- 对照 NAS JWKS(
- 请求体:
{"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_url | NAS base URL(也是期望的 JWT iss) |
cron.chronos.callback_url | agent 自己的公网 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 中转(本契约)是默认。