:Cron Scheduler在未来唤醒Agent)
0基础学会Agent Harness工程14Cron Scheduler在未来唤醒Agent本篇对应的官方文档Learn Claude Codes14 Cron Scheduler支撑CronJob、五段式匹配、持久化计划、调度线程与自动唤醒链。OpenAI Function Calling用于区分模型当前轮提出的schedule_crontool call 与 Harness 未来注入的调度事件。Python threading用于核对 daemon thread、Lock与进程关闭边界。Python queue用于对比当前加锁list与专用线程安全队列的语义。Kubernetes CronJob用于核对时区、错过时间、并发策略以及调度任务必须幂等的生产边界。本篇主要内容第 13 篇已经把慢工具移到 daemon thread用占位 tool result 完成当前调用配对再把真实结果作为新 observation 回填但没有新交互时前台仍不会自己醒来。本篇增加CronJob、五段式cron_matches()、.scheduled_tasks.json、cron_queue和queue_processor_loop()追踪时间命中如何变成一条新 user message并分析重启重复触发、错过运行、时区与多实例协调缺口。下篇预告目标、慢操作和未来事件都能持续运行后单个 Agent 的上下文与执行容量会成为新瓶颈。第 15 篇将引入 Agent Teams 和邮箱通信。一、慢任务已经进入后台为什么Agent还不会自己醒来第 13 篇保留了单线程 Agent Loop只把耗时 handler 移到后台。它的两次交接是首先用占位 tool result 告诉模型“已提交”之后由collect_background_results()把完成结果包装成task_notification。这能避免慢命令同步占住循环但 collector 只在后续 Agent Loop 运行时才会被调用。如果需求是“每天 09:00 检查构建状态”后台线程本身无法回答三个问题何时创建新工作进程重启后计划是否还在没有用户输入时谁来调用 Agent Loop。Background job 只执行已经提交的工作不保存未来计划也不产生时间事件。第 14 篇因此再区分四个对象schedule 是五段式时间规则CronJob 是包含规则、prompt 和生命周期的持久计划fired event 是某一分钟已经命中的具体事件Agent turn 是消费事件后开始的一轮模型交互。四者不能缩成一个“定时任务”词汇。CronJob 可以周期性产生多个 fired event每个 event 可能唤醒一次 Agent turnAgent 又可能在该轮提交一个 background job。观察下面的边界图重点看“工作何时已经存在”。Background Task 接收的是现在已经确定的调用所以返回task_id后可以马上交给 workerCron Schedule 保存的是未来条件条件命中以前根本没有待执行实例。把两者混用会让计划对象承担运行状态或让后台线程长期睡眠充当计时器重启后两种信息都会一起丢失。这条链中Cron Scheduler 只负责“在哪个时间点产生什么 prompt”不负责判断 prompt 最终需要调用哪个工具也不直接完成业务 Task。模型仍负责基于新输入选择行动Harness 负责时间匹配、入队、互斥和消息注入。完整唤醒链被拆为四层scheduler 线程检查时间并写入队列cron_queue隔离调度与消费速度queue processor 等待 Agent 空闲agent_loop()消费事件并注入messages。下图要沿箭头观察责任交接Scheduler 只产生事件不持有模型调用Queue 只暂存事件不解释 promptProcessor 只控制何时交付Agent Loop 才把事件变成模型可见状态。每层只有一个主要原因发生变化因此时间检查、排队速度和模型耗时不会互相绑死。分层的直接价值是不让调度线程直接进入模型调用。若 scheduler 在持有cron_lock时运行 Agent一次慢模型响应会阻塞所有时间检查和新任务注册。队列先保存 fired event调度器随后继续检查消费者再按前台容量交付。二、CronJob怎样保存并判断时间CronJob只有五个字段id提供稳定引用cron保存五段式表达式prompt是触发后要注入的消息recurring区分周期与首次命中后删除durable决定是否写入.scheduled_tasks.json。dataclassclassCronJob:id:strcron:strprompt:strrecurring:booldurable:boolscheduled_jobs:dict[str,CronJob]{}cron_queue:list[CronJob][]cron_lockthreading.Lock()agent_lockthreading.Lock()_last_fired:dict[str,str]{}代码先把“定义”与“运行时容器”并列放置CronJob描述一条规则四个全局对象则记录规则当前怎样被管理。字段少不代表状态少同一个 job 的定义可以长期留在scheduled_jobs某次命中又同时出现在cron_queue而_last_fired只记录最近一次触发水位。区分定义、事件和水位才能解释重启时究竟恢复了什么。观察下图时可以把CronJob看成中心稳定对象把字典、队列和锁看成不同阶段的运行时投影。它们的生命周期不同所以不能只序列化中心对象就宣称调度已经可靠恢复。scheduled_jobs是计划的当前内存索引cron_queue是已触发但尚未交给 Agent 的事件_last_fired用来防止 1 秒轮询在同一分钟内重复入队。它们分别表示“计划”、“待交付事件”和“本进程已触发记录”不能用其中一个代替其他两个。五段式 cron 按“分钟、小时、月内日期、月份、星期”排列。本地实现支持*、*/N、单值、范围和逗号列表不支持所有 cron 扩展。cron_matches()先让分钟、小时与月份全部匹配再单独处理 DOMday of month和 DOWday of week。进入代码前先锁定读图重点前三区域使用 ANDDOM 与 DOW 则根据是否为*选择单侧判断或 OR。这个分支不是普通布尔表达式的偶然写法而是 cron 日历语义的一部分。defcron_matches(cron_expr:str,dt:datetime)-bool:按五段式 cron 规则判断指定时间是否命中。minute,hour,dom,month,dowcron_expr.strip().split()dow_value(dt.weekday()1)%7minute_ok_cron_field_matches(minute,dt.minute)hour_ok_cron_field_matches(hour,dt.hour)month_ok_cron_field_matches(month,dt.month)dom_ok_cron_field_matches(dom,dt.day)dow_ok_cron_field_matches(dow,dow_value)ifnot(minute_okandhour_okandmonth_ok):returnFalseifdom*anddow*:returnTrueifdom*:returndow_okifdow*:returndom_okreturndom_okordow_ok这段函数可以按两级门禁阅读。第一层先淘汰分钟、小时、月份任一不符的时间点第二层只解决两个“日期选择器”怎样组合。把第二层直接改成dom_ok and dow_ok虽然更符合直觉却会改变常见 cron 语义。验证器负责表达式是否可解析匹配器负责某个具体时刻是否命中两者也不能合并成一次字符串检查。DOM 与 DOW 都受限时使用 OR是这段代码最容易误读的规则。例如0 9 1 * 1不是“每月 1 日且星期一 09:00”而是月内日期为 1 或星期一时命中。这一点如果不说明表达式看似合法实际运行频率却可能远高于预期。validate_cron()在注册时检查字段数量、数值边界、step 大于零以及范围起止顺序。这能阻止61 * * * *、*/0 * * * *或只有四段的明显错误却不检验“某个日期在某月是否存在”也不支持时区、别名、最后一天等扩展。公开边界应写成“教学子集”不应宣称是完整 cron parser。持久化只保存durableTrue的 CronJobdefsave_durable_jobs():把需要跨进程保留的计划写入 JSON。durable_jobs[asdict(job)forjobinscheduled_jobs.values()ifjob.durable]DURABLE_PATH.write_text(json.dumps(durable_jobs,indent2))defload_durable_jobs():启动时恢复合法的持久计划。ifnotDURABLE_PATH.exists():returnforiteminjson.loads(DURABLE_PATH.read_text()):jobCronJob(**item)ifvalidate_cron(job.cron)isNone:scheduled_jobs[job.id]job保存和加载形成的是规则快照而不是触发事务。save_durable_jobs()只筛选 durable job 并覆盖 JSONload_durable_jobs()只重建对象索引这里没有保存某次 scheduled time 是否已产生事件、事件是否被 Agent 消费或上次执行是否成功。因此“重启后还能看到计划”只能证明定义恢复不能证明调度进度恢复。下图把这条边界拆成左右两条路径左侧规则能跨重启回来右侧会话计划随进程消失两侧都没有证明触发事件能够可靠投递。读图时应把“计划持久化”和“事件持久化”当成两项独立能力。持久化的是“什么时候应该触发”不是“最近一次已经触发到哪里”。_last_fired和cron_queue都没有写盘所以重启恢复了计划却不能恢复已入队事件或去重水位。这是理解后面重复触发的关键。三、时间命中怎样变成一轮新的模型输入cron_scheduler_loop()每秒获取datetime.now()将当前时间压缩成YYYY-MM-DD HH:MM标记。某个 job 命中且当前分钟尚未记录时它被追加到cron_queue_last_fired[job.id]同时更新。这让 1 秒轮询不会在一分钟内入队 60 次。defcron_scheduler_loop():轮询当前时间将首次命中的任务写入队列。whileTrue:time.sleep(1)nowdatetime.now()minute_markernow.strftime(%Y-%m-%d %H:%M)withcron_lock:forjobinlist(scheduled_jobs.values()):ifnotcron_matches(job.cron,now):continueif_last_fired.get(job.id)minute_marker:continuecron_queue.append(job)_last_fired[job.id]minute_markerifnotjob.recurring:scheduled_jobs.pop(job.id,None)ifjob.durable:save_durable_jobs()循环中的顺序非常关键先判断当前分钟是否命中再检查_last_fired随后把事件入队并更新水位。若先更新水位、入队前进程崩溃该分钟会被永久视为已处理若先入队、更新水位前崩溃重启或下一次轮询可能重复入队。教学代码用同一把进程锁缩小窗口却没有事务因此只能提供 best-effort 的进程内一致性。下面的时间线应同时看主线和红色支线主线说明一分钟内的 60 次轮询怎样折叠成一次支线说明重启后内存水位消失为什么相同机制不能继续去重。minute marker 只是当前进程内的去重不是分布式锁。如果同一个.scheduled_tasks.json被两个进程加载两者都有自己的_last_fired同一分钟会各自入队一次。如果进程在已触发的同一分钟重启新的_last_fired为空持久化 job 也可能再次入队。queue processor 解决的是“何时安全进入前台 Agent”。它轮询has_cron_queue()使用agent_lock.acquire(blockingFalse)尝试抢占当前进程的 Agent 执行权。若用户轮次正在运行它不阻塞等锁而是稍后再试取得锁后再次检查队列避免状态在抢锁期间变化。观察时序图时重点看锁覆盖的范围从取出事件到agent_loop()完成都属于同一个受保护回合。若只锁住messages.append()用户回合和定时回合仍可能交叉调用模型、各自基于不同历史生成结果最后再以不可预测的顺序写回。agent_loop()获得控制权后调用consume_cron_queue()把每个 fired job 转换成{role: user, content: f[Scheduled] {job.prompt}}。从模型的视角看这是一条新环境输入从 Harness 的视角看它是时间事件到 message 的适配。适配后的roleuser不表示真实的人在这一刻键入文字它表示这是一条需要模型处理的新输入而不是对旧 tool call 的回答。应用还应保留job_id、scheduled_time和触发来源等结构化元数据避免仅靠[Scheduled]文本前缀做审计和去重。这里存在两个不同的协议交点。当前轮注册计划时模型可能输出schedule_cron(cron, prompt, recurring, durable)Harness 执行 handler 后用原tool_call_id回填“已注册”。未来时间命中时早已没有那个待回答的 tool call因此不能复用旧 ID而要建立新消息。下图的阅读重点是中间的时间分界上半段仍处于创建计划的当前请求必须完成 assistant 与 tool 的 ID 配对下半段是独立发生的未来事件只能开启新回合。两段共享job_id便于追踪但不能共享tool_call_id。OpenAI Function Calling 规范负责当前 tool call 与 tool result 的结构化配对不提供定时器、持久队列或未来唤醒。[Scheduled]前缀、CronJob JSON 和 queue processor 都是 Harness 内部设计。即使使用 OpenAI-compatible endpoint也不能把这些本地组件说成 API 自带功能。不调用模型端点仍可以对“周期计划”做一次静态状态追踪阶段输入或动作持久状态队列/消息状态注册*/5 * * * *durableTrueJSON 保存 CronJob无 fired event启动load_durable_jobs()恢复到scheduled_jobs队列为空命中cron_matchesTrue周期 job 继续保留job 进入cron_queue唤醒queue processor 取得agent_lock无变化队列转为 user message消费Agent Loop 处理 prompt无变化模型可提出新 tool call一次性 job 的差别只在命中后从scheduled_jobs删除并重写 durable 文件。它不是精确到某年某月某日的一次性时间数据类型而是“这条 cron 规则首次命中后删除”。如果需要绝对时间、延迟任务或补偿截止时间应用专门的run_at、deadline和 timezone 字段表达。四、能按时入队为什么仍不等于生产调度器教学代码已经具备计划注册、表达式验证、时间匹配、一分钟去重、持久化、队列交付和 Agent 互斥但这些对象都只在单进程、本地文件和一把锁的边界内成立。生产问题主要出现在“调度器不确定自己是否已经成功交付”的缝隙中。时区不明确。datetime.now()使用运行主机的本地时间CronJob 没有 timezone 字段。迁移主机、容器时区不同或夏令时切换都可能让触发时刻变化。生产计划应显式保存 IANA timezone内部时间比较使用带时区对象。重启可能重复触发。_last_fired未持久化在已命中的同一分钟重启后job 可以再入队。解法不是只把字典写盘而是用(job_id, scheduled_time)构造唯一触发键将产生事件和去重水位放进同一事务。宕机期间的触发会错过。重启只加载 CronJob不回放上次检查到当前时间之间的命中点。需要明确 misfire policy立即补跑、仅补最近一次、在 deadline 内补跑或全部跳过。重复触发与漏触发都可能发生。Kubernetes CronJob 文档也明确把调度描述为近似行为在某些情况下可能创建两个 Job 或不创建因此实际工作必须幂等。本地 Harness 更不应假设精确一次。并发策略缺失。上一次同一 job 还在运行时新触发是允许并行、跳过还是替换旧运行当前agent_lock只将本进程的 Agent turn 串行化没有为每个 CronJob 定义运行策略。持久化不是事务。.scheduled_tasks.json直接覆盖并发注册和取消可能丢更新中途崩溃可能留下坏 JSON。load_durable_jobs()对异常的宽泛忽略还会把数据损坏变成“看起来没有任务”。多实例缺少领导权。多个进程或机器会同时扫描同一批计划。需要 leader election、数据库锁、分区所有权或支持幂等的多消费者队列而不是跨机器共享threading.Lock。队列没有 durable acknowledgement。cron_queue是内存list取出后在模型调用前崩溃会丢事件反向调整顺序又会带来重复。生产队列需要可见性超时、acknowledgement、死信和重放机制。生产架构可以把本篇的四层继续保留但替换每层的可靠性实现Scheduler service 使用带时区时间和持久水位durable queue 保存(job_id, scheduled_time)事件worker 使用租约和幂等键执行Agent session service 只在获得可消费事件后启动一轮。这样即使替换数据库、队列或模型服务“计划—触发—交付—消费”的逻辑仍然稳定。第 14 篇的完整增量是CronJob定义未来计划validate_cron()和cron_matches()定义教学子集的合法性与命中规则.scheduled_tasks.json让 durable job 跨进程恢复scheduler 把命中点写入cron_queuequeue processor 使用agent_lock等待前台空闲Agent Loop 最后把事件变成新 user message。到这里持久运行模块形成了三层连续关系第 12 篇保存“要做什么”第 13 篇处理“慢操作如何不阻塞”第 14 篇补上“何时主动开始”。但所有事件最终仍进入同一个session_history和同一把agent_lock。当多项工作同时到来时单 Agent 会成为并发瓶颈第 15 篇将引入多个独立 Agent、邮箱和消息路由开始处理“谁来做”。