ZeroClaw — Cron 스케줄러
Layer 3 학습. 실제 소스 코드 기반. 파일:
src/cron/types.rs,src/cron/schedule.rs,src/cron/scheduler.rs,src/cron/store.rs
한 줄 정의
Cron = 시간 기반으로 Shell 명령이나 LLM 에이전트 작업을 자동 실행하는 스케줄러.
일반 cron과 다른 점 — Job이 두 종류야:
JobType::Shell— 기존 cron과 동일. SecurityPolicy 검증 후sh -c실행.JobType::Agent— LLM에게 프롬프트를 던져 에이전트 루프 전체를 실행.
핵심 타입
Schedule — 3가지 트리거 방식
pub enum Schedule {
Cron {
expr: String, // "0 9 * * 1-5" (5필드 표준 crontab)
tz: Option<String>, // "Asia/Seoul", None이면 UTC
},
At {
at: DateTime<Utc>, // 특정 시각 1회 실행
},
Every {
every_ms: u64, // 60000 = 1분마다 반복
},
}
CronJob
pub struct CronJob {
pub id: String,
pub expression: String, // 원본 표현식 (표시용)
pub schedule: Schedule,
pub command: String, // Shell 명령 (JobType::Shell)
pub prompt: Option<String>, // LLM 프롬프트 (JobType::Agent)
pub name: Option<String>,
pub job_type: JobType, // Shell | Agent
pub session_target: SessionTarget, // Isolated | Main
pub model: Option<String>, // 모델 오버라이드
pub enabled: bool,
pub delivery: DeliveryConfig, // 결과 배송 설정
pub delete_after_run: bool, // 1회 실행 후 삭제 (At + true)
pub allowed_tools: Option<Vec<String>>, // Agent 전용 도구 제한
pub next_run: DateTime<Utc>,
pub last_run: Option<DateTime<Utc>>,
pub last_status: Option<String>,
pub last_output: Option<String>,
// ...
}
DeliveryConfig — 결과 채널 배송
pub struct DeliveryConfig {
pub mode: String, // "none" | "announce"
pub channel: Option<String>, // "telegram" | "discord" | "slack" | ...
pub to: Option<String>, // 수신자 ID
pub best_effort: bool, // true: 배송 실패해도 Job은 성공
}
Schedule 파싱 — normalize_expression()
cron crate와 표준 crontab의 요일 번호가 달라서 변환 필요:
표준 crontab: 0=일, 1=월, 2=화 ... 6=토, 7=일(별칭)
cron crate: 1=일, 2=월, 3=화 ... 7=토
// 5필드 → 6필드 변환 (초 필드 0 추가) + 요일 번호 조정
"0 9 * * 1-5" → "0 0 9 * * 2-6"
// ^ sec추가 ^ 1→2, 5→6 (월~금)
// 6/7필드는 이미 cron crate 형식 → 그대로
"0 0 9 * * 1-5" → "0 0 9 * * 1-5"
타임존 지원:
Schedule::Cron { expr: "0 9 * * *", tz: Some("America/Los_Angeles") }
// → UTC로 변환해서 저장 (2026-02-16 UTC 기준 17:00)
run() — 스케줄러 메인 루프
startup:
catch_up_on_startup = true (기본)이면:
→ all_overdue_jobs() (max_tasks 제한 없음)
→ 놓친 작업 전부 즉시 실행 (머신 다운 후 복구 목적)
loop:
interval.tick() (기본 5초 폴링, MIN_POLL_SECONDS = 5)
│
├─ health::mark_component_ok("scheduler")
│
├─ due_jobs(config, now) → Vec<CronJob>
│ (next_run <= now, enabled=true, max_tasks 제한 적용)
│
└─ process_due_jobs()
buffer_unordered(max_concurrent) ← 동시 실행 수 제어
각 Job → execute_and_persist_job()
execute_job_with_retry() — 재시도 로직
attempt 0 → 실행
실패 + "blocked by security policy:" → 즉시 반환 (재시도 불가)
실패 + 기타 → backoff + jitter 후 재시도
attempt 1 → 실행
...
attempt N (= scheduler_retries) → 마지막 결과 반환
backoff: 200ms → 400ms → ... → 최대 30,000ms (지수 증가)
jitter: 0~250ms (타임스탬프 기반)
Shell Job 실행 — run_job_command()
1. security.can_act() → ReadOnly면 차단
2. security.is_rate_limited() → 한도 초과면 차단
3. validate_shell_command_with_security()
= is_command_allowed() + command_risk_level() + 승인 게이트
approved = false (스케줄러는 사전 승인 없음)
4. security.forbidden_path_argument() → 금지 경로 인수 차단
5. security.record_action() → 액션 카운터 증가
6. build_cron_shell_command():
sh -c <command>
- current_dir: workspace_dir
- stdin: null (비대화형)
- stdout/stderr: piped (캡처)
- kill_on_drop: true (타임아웃 안전 처리)
7. timeout: 120초 (SHELL_JOB_TIMEOUT_SECS)
-l (login shell) 플래그 의도적으로 제거 — 로그인 쉘은 전체 프로파일 로드로 느리고 부작용 있음.
Agent Job 실행 — run_agent_job()
1. security.can_act()
2. security.is_rate_limited()
3. security.record_action()
4. 프롬프트 구성:
"[cron:{job.id} {name}] {prompt}"
5. crate::agent::run(config, prompt, allowed_tools) → agent loop 전체
= LLM 호출 + tool call loop + 응답
6. 응답 반환
allowed_tools가 있으면 해당 도구만 사용 가능 (보안 격리).
persist_job_result() — 실행 후 처리
실행 완료
│
├─ deliver_if_configured() (mode = "announce"이면)
│ → deliver_announcement(channel, target, output)
│ best_effort = true: 배송 실패해도 Job 성공 유지
│ best_effort = false: 배송 실패 → Job 실패
│
├─ record_run() → SQLite에 실행 이력 저장
│
├─ is_one_shot_auto_delete? (At + delete_after_run = true)
│ 성공 → remove_job() (Job 완전 삭제)
│ 실패 → enabled = false (비활성화)
│
└─ reschedule_after_run()
→ next_run 갱신
→ At 스케줄: 실행 후 enabled = false (과거 시각 재실행 방지)
→ Cron/Every: 다음 실행 시각 계산 후 업데이트
deliver_announcement() — 지원 채널
"telegram" → TelegramChannel::send()
"discord" → DiscordChannel::send()
"slack" → SlackChannel::send()
"mattermost" → MattermostChannel::send()
"signal" → SignalChannel::send()
"matrix" → MatrixChannel::send() (feature = "channel-matrix")
"qq" → QQChannel::send()
기타 → Err("unsupported delivery channel")
Job 수명주기 비교
| 종류 | 트리거 | 실행 후 | |------|--------|---------| | Cron (반복) | 표현식 기반 | next_run 재계산 → 계속 실행 | | Every (반복) | ms 간격 | now + every_ms → 계속 실행 | | At (1회, delete_after_run=true) | 특정 시각 | 성공 → 삭제, 실패 → 비활성화 | | At (1회, delete_after_run=false) | 특정 시각 | enabled = false (reschedule) |
높은 빈도 경고
// Agent Job이 5분보다 자주 실행되면 경고 로그
fn warn_if_high_frequency_agent_job(job: &CronJob) {
// Every: every_ms < 5 * 60 * 1000 이면 경고
// Cron: 연속 두 실행 간격 < 5분이면 경고
}
config.toml 설정
[cron]
enabled = true
catch_up_on_startup = true # 재시작 후 놓친 작업 즉시 실행
[scheduler]
max_tasks = 10 # 한 번에 처리할 최대 작업 수
max_concurrent = 4 # 동시 실행 수
[reliability]
scheduler_poll_secs = 10 # 폴링 간격 (최소 5초)
scheduler_retries = 2 # 실패 시 재시도 횟수
provider_backoff_ms = 500 # 재시도 초기 대기 (ms)
LLM에서 Cron Job 등록 — cron_add 도구
LLM이 직접 작업을 등록할 수 있어:
{
"schedule": {"kind": "cron", "expr": "0 9 * * 1-5"},
"job_type": "agent",
"prompt": "오늘의 날씨를 확인하고 Telegram으로 알려줘",
"delivery": {
"mode": "announce",
"channel": "telegram",
"to": "12345678",
"best_effort": true
}
}
Schedule이 LLM에서 올 때 이중 직렬화 문제(문자열 안에 JSON 문자열) 처리:
fn deserialize_maybe_stringified<T>(v: &Value) -> Result<T> {
// Value::Object 시도
// 실패 + Value::String이면 → 내부 문자열 파싱 후 재시도
}
관련
- daemon — Scheduler 컴포넌트를 spawn_component_supervisor로 감시
- security-policy — Shell Job 실행 시 SecurityPolicy 검증
- agent-loop — Agent Job이 crate::agent::run() 호출
- channel-system — deliver_announcement가 채널 Channel::send() 사용
- config-schema — [cron], [scheduler] 설정 섹션
- overview — ZeroClaw 학습 지도