research

ZeroClaw — Cron 스케줄러

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 학습 지도