Files
analysis_claude_code/s14_cron_scheduler/README.zh.md

256 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# s14: Cron Scheduler — 按时间表生产工作
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s18 → s19
> *"按时间表生产工作, 调度与执行解耦"* — cron 调度, 持久化或会话级。
>
> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。
---
## 问题
闹钟不需要你盯着它才会响。你设好 7:00到点它自己响你在睡觉、在洗澡、在做饭它都照响不误。
s13 让 Agent 能后台执行慢操作但所有操作仍然是你手动触发的。你说一句Agent 动一下。"每天早上 9 点跑测试"、"每 30 分钟检查 CI 状态",这些周期性任务不该需要人每次来推。
---
## 解决方案
![Cron Scheduler Overview](images/cron-scheduler-overview.svg)
本章新增独立的 cron 调度线程:每秒检查一次,把到期任务写入 `cron_queue`,再由 queue processor 在 Agent 空闲时自动交付。
手动 vs 定时:
| | 手动触发 (s13) | 定时触发 (s14) |
|---|---|---|
| 触发者 | 用户输入 | 调度线程 |
| 触发时机 | 随时 | cron 表达式指定 |
| 需要人参与 | 是 | 否(调度器自动入队,空闲时自动交付) |
| 持久性 | — | durable 跨重启 |
---
## 工作原理
### 四层模型
Cron 调度分四层:
1. **Scheduler**daemon 线程,每秒轮询,判断时间到了没有
2. **Queue**`cron_queue`,调度线程写入已触发任务
3. **Queue Processor**:发现队列非空且 Agent 空闲,启动一轮 agent_loop
4. **Consumer**agent_loop 从队列消费,注入到 messages
### CronJob: 数据结构
每个 cron 任务是一个 `CronJob` 对象:
```python
@dataclass
class CronJob:
id: str
cron: str # "0 9 * * *" (五段式 cron 表达式)
prompt: str # 触发时注入给 Agent 的消息
recurring: bool # True=周期性False=一次性
durable: bool # True=写磁盘,跨会话保留
```
Cron 表达式五段式Unix 用了 50 年:
```
分钟 小时 日 月 星期
* * * * * 每分钟
0 9 * * * 每天早上 9:00
*/5 * * * * 每 5 分钟
0 9 * * 1-5 工作日早上 9:00
```
支持 `*``*/N``N``N-M``N,M,...`
### cron_matches: 五段式匹配
标准 cron 语义分钟、小时、月必须全部匹配DOM和星期DOW同时被约束时任一匹配即可OR
```python
def cron_matches(cron_expr: str, dt: datetime) -> bool:
fields = cron_expr.strip().split()
if len(fields) != 5:
return False
minute, hour, dom, month, dow = fields
dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0
m = _cron_field_matches(minute, dt.minute)
h = _cron_field_matches(hour, dt.hour)
dom_ok = _cron_field_matches(dom, dt.day)
month_ok = _cron_field_matches(month, dt.month)
dow_ok = _cron_field_matches(dow, dow_val)
if not (m and h and month_ok):
return False
# DOM and DOW: both constrained → either matching is enough (OR)
dom_unconstrained = dom == "*"
dow_unconstrained = dow == "*"
if dom_unconstrained and dow_unconstrained:
return True
if dom_unconstrained:
return dow_ok
if dow_unconstrained:
return dom_ok
return dom_ok or dow_ok
```
### 独立调度线程: 每秒轮询
调度器跑在独立的 daemon 线程里,不依赖 agent_loop 是否在执行。单个 job 异常不会杀掉整个线程:
```python
def cron_scheduler_loop():
while True:
time.sleep(1)
now = datetime.now()
minute_marker = now.strftime("%Y-%m-%d %H:%M")
with cron_lock:
for job in list(scheduled_jobs.values()):
try:
if cron_matches(job.cron, now):
if _last_fired.get(job.id) != minute_marker:
cron_queue.append(job)
_last_fired[job.id] = minute_marker
if not job.recurring:
scheduled_jobs.pop(job.id, None)
if job.durable:
save_durable_jobs()
except Exception as e:
print(f"[cron error] {job.id}: {e}")
```
关键设计:
- **独立于 agent_loop**:即使 agent_loop 没在跑,调度器也在后台检查时间
- **date-aware minute_marker**:用 `"YYYY-MM-DD HH:MM"` 防止同一分钟重复触发,同时不会在第二天跳过
- **单 job try/except**:一个坏 job 不会拖垮整个调度线程
- **一次性任务**:触发后自动从 scheduled_jobs 里删除
### Queue Processor + agent_loop: 交付端
queue processor 不检查时间,只负责在队列有任务且 Agent 空闲时拉起一轮执行:
```python
def queue_processor_loop():
while True:
time.sleep(0.2)
if not has_cron_queue():
continue
if not agent_lock.acquire(blocking=False):
continue
try:
if has_cron_queue():
run_agent_turn_locked()
finally:
agent_lock.release()
```
agent_loop 也不负责检查时间,它只从 `cron_queue` 里拿已触发的任务,注入到 messages 里:
```python
fired = consume_cron_queue()
for job in fired:
messages.append({"role": "user",
"content": f"[Scheduled] {job.prompt}"})
```
生产者调度线程、交付者queue processor和消费者agent_loop通过 `cron_queue``cron_lock``agent_lock` 解耦。
### 校验:防止坏 cron 杀掉调度器
`schedule_job` 在注册前校验 cron 表达式,非法的直接返回错误:
```python
def schedule_job(cron, prompt, recurring=True, durable=True):
err = validate_cron(cron)
if err:
return err
# ... register job
```
从磁盘加载 durable job 时也会跳过非法表达式,避免单个坏任务拖垮启动。
### Durable vs Session-only
- **Durable**:任务定义写进 `.scheduled_tasks.json`。Agent 重启后加载文件,恢复任务。
- **Session-only**只在内存里。Agent 关闭就没了。
> **重要前提**cron 调度器必须在 Agent 进程内跑。进程关闭调度也停。Durable 只意味着任务定义跨重启保留,下次 Agent 启动时调度器才会发现"该触发了"并触发。如果需要"即使应用关闭也能定时跑",请用系统 crontab 或 systemd timer。
### 合起来跑
```
1. 启动时:
load_durable_jobs() → 从 .scheduled_tasks.json 恢复持久化任务
Thread(cron_scheduler_loop, daemon=True).start() → 调度线程开始轮询
Thread(queue_processor_loop, daemon=True).start() → 队列处理器等待交付
2. 注册任务:
schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True)
→ CronJob 写入 scheduled_jobs + .scheduled_tasks.json
3. 每 2 分钟:
调度线程检查 → cron_matches 返回 True → cron_queue.append(job)
→ queue processor 发现 Agent 空闲 → agent_loop consume_cron_queue
→ 注入 "[Scheduled] run date"
→ LLM 收到消息,执行 date 命令
4. 关闭进程:
调度线程跟着停daemon=True
.scheduled_tasks.json 还在磁盘上
下次启动 → load_durable_jobs → 任务恢复
```
---
## 相对 s13 的变更
| 组件 | 之前 (s13) | 之后 (s14) |
|------|-----------|-----------|
| 触发方式 | 用户手动触发 | 调度线程自动入队 |
| 新类型 | — | CronJob dataclass (id, cron, prompt, recurring, durable) |
| 新函数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |
| 新存储 | — | .scheduled_tasks.json (durable) + 内存 (session-only) |
| 线程 | 后台执行线程 | + 调度线程 (daemon, 1s 轮询) + queue processor 线程 |
| 队列 | background_results | + cron_queue (调度线程写, queue processor 交付, agent_loop 消费) |
| 工具 | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |
---
## 试一下
```sh
cd learn-claude-code
python s14_cron_scheduler/code.py
```
试试这些 prompt
1. `Schedule a task to print the current date every 2 minutes`
2. `List all cron jobs`
3. `Create a one-shot reminder in 1 minute to check the build status`
4. `Cancel the recurring job and verify with list_crons`
观察重点调度线程是否在独立运行cron 任务是否在正确的时间点触发?不输入新 prompt 时,是否也出现 `[queue processor]` 并自动执行durable job 是否写入了 `.scheduled_tasks.json`
---
## 接下来
一个 Agent 能做很多事了,能计划、能压缩、能后台、能定时。但有些任务太大了,不是一个 Agent 能搞定的。
"重构整个后端"把认证模块、数据库层、API 路由、测试全部翻新。一个 Agent 的注意力是有限的,这需要一个团队。
s15 Agent Teams → 一个 Agent 不够,组队吧。持久队友 + 异步收件箱。
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->