Skip to content
源码分析手册

scheduled-tasks:不是后台守护线程,而是会话内的 Cron Prompt 调度器

对应官方文档:claude-code-docs/docs/scheduled-tasks.md 里的 Run prompts on a schedule

一句话讲清楚

scheduled-tasks 的本质,并不是 Claude Code 背后运行着一个神秘的“定时执行服务”,也不是 /loop 命令本身具备某种特有的原子能力。

它真正实现的是一套 Prompt 延迟注入系统。它将“未来某个时间点需要执行的任务”记录下来,当时间点到达且当前会话处于空闲(Idle)状态时,将预设的 Prompt 重新“喂”给 Claude 的 Query Loop。

你可以把它看作是一个会话级的“闹钟”,到点后会自动帮你按一下键盘。它的核心逻辑围绕以下三个原子工具展开:

  • CronCreate:任务登记。
  • CronList:任务展示(合并内存与磁盘)。
  • CronDelete:任务清理。

根据任务的生命周期,它分为两类存储形态:

  1. Session-only:仅存于当前进程内存,随进程退出而消失。
  2. Durable(持久化):写入项目目录下的 <project>/.claude/scheduled_tasks.json,支持跨会话重启。

实现机制

虽然 /loop 是最常见的入口,但 claude-code-opensource/src/skills/bundled/loop.ts 仅仅是一个封装。它负责将自然语言(如 "every 5m")解析为 Cron 表达式,调用 CronCreate 工具进行登记,并立即触发第一次执行。

真正支撑起这一整套调度机制的,是以下几个核心模块:

1. 任务的“等级制度”与校验

claude-code-opensource/src/tools/ScheduleCronTool/CronCreateTool.ts 中,系统对任务创建设定了严格的限制:

  • 同一项目下最多允许 50 个任务。
  • Teammate(子代理)不允许创建 Durable 任务。这是因为子代理本身的生命周期不跨会话,持久化任务在重启后会成为无主孤儿。
  • 只有校验通过的任务才会触发 scheduledTasksEnabled 状态位,进而唤醒调度引擎。

2. 双速存储与并发锁

任务存储在 claude-code-opensource/src/utils/cronTasks.ts 中。由于支持持久化任务,为了避免在同一目录下开启多个 Claude 会话(例如在两个终端窗口同时运行)导致任务被多次触发,系统引入了 claude-code-opensource/src/utils/cronTasksLock.ts。 它通过 .claude/scheduled_tasks.lock 文件的原子创建和 PID 活跃检查,确保同一时间只有一个 Master Session 负责调度磁盘上的持久化任务。

3. Idle-only 注入策略

调度核心位于 claude-code-opensource/src/utils/cronScheduler.ts。不同于后台线程直接在后台乱跑,它的触发是非常“懂礼貌”的:

  • 它会每秒轮询一次到期任务。
  • 必须等 Claude 当前不忙时(即没有正在执行的任务或等待用户输入的状态),才会将任务注入队列。
  • 注入的 Prompt 会被标记为 priority: 'later'isMeta: true。主会话会先发一条 scheduled_task_fire 的系统提示,随后才正式运行任务。

4. 确定性抖动(Deterministic Jitter)

为了防止大规模客户端在整点(如 9:00)同时向 API 发送请求,cronTasks.ts 实现了一种基于任务 ID 哈希的抖动机制:

  • 周期性任务(Recurring)会在触发点后延后约 10% 的周期时长(上限 15 分钟)。
  • 一次性任务(One-shot)如果恰好落在整点/半点,则会随机提前约 90 秒触发。

5. 错过任务的“先问后做”

当 Claude 重启并加载持久化任务时,如果发现某个 One-shot 任务在离线期间已经到期,buildMissedTaskNotification(...) 会构造一个专门的通知,询问用户是否现在补跑,而不是武断地直接执行。这种设计极大地降低了陈旧指令带来的意外风险。

6. 云端调度与远程代理(Cloud Scheduling)

对于需要在本地 CLI 关闭后依然保持运行的任务,Claude 提供了一套远程触发机制(Remote Triggers)

  • CCR (Claude Cloud Runtime):不同于本地 Cron,远程任务是在 Anthropic 的云端基础设施中独立运行的。每个触发器都会启动一个完全隔离的沙盒环境。
  • Environment ID:远程调度需要绑定一个云端环境 ID。通过 /schedule 命令,用户可以配置在特定时间点启动一个远程 Agent 实例。
  • 协作调度:本地持久化任务(Durable Tasks)可以通过 RemoteTriggerTool 与云端协作。例如,本地任务可以在完成数据处理后,通过调用远程触发器启动一个持续 1 小时的云端监控任务,而无需保持本地终端开启。
  • 限制:云端调度的最小触发间隔通常为 1 小时,且由于运行在隔离沙盒中,远程 Agent 无法直接访问用户的本地非 Git 管理文件。

别踩这些坑

  • 非独立 Worker:对于本地任务,如果你的终端关了,所有的定时任务都会停摆(Session-only 直接丢失,Durable 需等下次启动)。
  • 云端持久性:远程触发器(Remote Triggers)是真正的“云端 Durable”,即使本地关机,它们也会在云端按时触发。
  • 非精确调度:由于受到 Idle-only 策略和 Jitter 抖动的影响,本地任务的触发时间通常会有秒级到分钟级的偏差。
  • Teammate 限制:子代理创建的任务不能持久化,只能随该子代理的消亡而消亡。
  • 时区行为:本地 Cron 按本地时区解析;而远程云端调度通常使用 5 字段的标准 Cron 表达式并遵循 UTC 时间。

继续探索

源码锚点

  • claude-code-opensource/src/skills/bundled/loop.ts/loop 命令的前门逻辑。
📄 src/skills/bundled/loop.ts — `/loop` 命令的前门逻辑。L9-16 of 93
typescript
const DEFAULT_INTERVAL = '10m'

const USAGE_MESSAGE = `Usage: /loop [interval] <prompt>

Run a prompt or slash command on a recurring interval.

Intervals: Ns, Nm, Nh, Nd (e.g. 5m, 30m, 2h, 1d). Minimum granularity is 1 minute.
If no interval is specified, defaults to ${DEFAULT_INTERVAL}.
  • claude-code-opensource/src/skills/bundled/scheduleRemoteAgents.ts:远程 Agent 调度的核心实现,包含环境发现与触发器配置。
📄 src/skills/bundled/scheduleRemoteAgents.ts — 远程 Agent 调度的核心实现,包含环境发现与触发器配置。L174-180 of 448
typescript
  return `# Schedule Remote Agents

You are helping the user schedule, update, list, or run **remote** Claude Code agents. These are NOT local cron jobs — each trigger spawns a fully isolated remote session (CCR) in Anthropic's cloud infrastructure on a cron schedule. The agent runs in a sandboxed environment with its own git checkout, tools, and optional MCP connections.

## First Step

${firstStep}
  • claude-code-opensource/src/tools/ScheduleCronTool/CronCreateTool.ts:任务创建、数量限制与 Teammate 校验。
📄 src/tools/ScheduleCronTool/CronCreateTool.ts — 任务创建、数量限制与 Teammate 校验。L105-114 of 158
typescript
    // Teammates don't persist across sessions, so a durable teammate cron
    // would orphan on restart (agentId would point to a nonexistent teammate).
    if (input.durable && getTeammateContext()) {
      return {
        result: false,
        message:
          'durable crons are not supported for teammates (teammates do not persist across sessions)',
        errorCode: 4,
      }
    }
  • claude-code-opensource/src/tools/RemoteTriggerTool/prompt.ts:定义了模型如何与云端触发器进行交互。
📄 src/tools/RemoteTriggerTool/prompt.ts — 定义了模型如何与云端触发器进行交互。L1-10 of 16
typescript
export const REMOTE_TRIGGER_TOOL_NAME = 'RemoteTrigger'

export const DESCRIPTION =
  'Manage scheduled remote Claude Code agents (triggers) via the claude.ai CCR API. Auth is handled in-process — the token never reaches the shell.'

export const PROMPT = `Call the claude.ai remote-trigger API. Use this instead of curl — the OAuth token is added automatically in-process and never exposed.

Actions:
- list: GET /v1/code/triggers
- get: GET /v1/code/triggers/{trigger_id}
  • claude-code-opensource/src/utils/cronTasks.ts:任务模型定义、内存/磁盘分流存储与抖动计算。
📄 src/utils/cronTasks.ts — 任务模型定义、内存/磁盘分流存储与抖动计算。L30-59 of 459
typescript
export type CronTask = {
  id: string
  /** 5-field cron string (local time) — validated on write, re-validated on read. */
  cron: string
  /** Prompt to enqueue when the task fires. */
  prompt: string
  /** Epoch ms when the task was created. Anchor for missed-task detection. */
  createdAt: number
  /**
   * Epoch ms of the most recent fire. Written back by the scheduler after
   * each recurring fire so next-fire computation survives process restarts.
   * The scheduler anchors first-sight from `lastFiredAt ?? createdAt` — a
   * never-fired task uses createdAt (correct for pinned crons like
   * `30 14 27 2 *` whose next-from-now is next year); a fired-before task
   * reconstructs the same `nextFireAt` the prior process had in memory.
   * Never set for one-shots (they're deleted on fire).
   */
  lastFiredAt?: number
  /** When true, the task reschedules after firing instead of being deleted. */
  recurring?: boolean
  /**
   * When true, the task is exempt from recurringMaxAgeMs auto-expiry.
   * System escape hatch for assistant mode's built-in tasks (catch-up/
   * morning-checkin/dream) — the installer's writeIfMissing() skips existing
   * files so re-install can't recreate them. Not settable via CronCreateTool;
   * only written directly to scheduled_tasks.json by src/assistant/install.ts.
   */
  permanent?: boolean
  /**
   * Runtime-only flag. false → session-scoped (never written to disk).
  • claude-code-opensource/src/utils/cronScheduler.ts:调度引擎核心,负责 Idle 检测、过期检查与任务注入。
📄 src/utils/cronScheduler.ts — 调度引擎核心,负责 Idle 检测、过期检查与任务注入。L17-20 of 566
typescript
  type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  logEvent,
} from '../services/analytics/index.js'
import { cronToHuman } from './cron.js'
  • claude-code-opensource/src/utils/cronTasksLock.ts:跨进程单主控锁机制,防止任务双响。
📄 src/utils/cronTasksLock.ts — 跨进程单主控锁机制,防止任务双响。L23-30 of 196
typescript
const LOCK_FILE_REL = join('.claude', 'scheduled_tasks.lock')

const schedulerLockSchema = lazySchema(() =>
  z.object({
    sessionId: z.string(),
    pid: z.number(),
    acquiredAt: z.number(),
  }),

基于 Claude Code v2.1.88 开源快照的深度分析