Scheduling & Triggers

Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source: crates/cheng-core/src/domain/scheduled_task.rs, crates/cheng-core/src/services/tiered_scheduler.rs, crates/cheng-nodes/src/nodes/builtin/tools/schedule.rs, crates/cheng-api/src/rest/handlers/schedule/mod.rs

A workflow can start four ways: a user runs it, a channel message arrives, another workflow calls it as a subflow, or the clock fires. This page is about the clock.

The tiered scheduler

Scheduling in ChengOS is not a cron process. It is a tiered scheduler designed to be correct across multiple nodes and process restarts:

       far future                    soon                   imminent
  ┌──────────────────┐      ┌──────────────────┐      ┌──────────────────┐
  │   PostgreSQL     │ ───► │   Redis (warm)   │ ───► │  hot wheel       │
  │  (source of      │      │  warm_window     │      │  DelayQueue      │
  │   truth)         │      │  300 s default   │      │  60 s default    │
  └──────────────────┘      └──────────────────┘      └──────────────────┘
          ▲                                                    │
          │            PG sweeper re-enqueues stale            ▼
          └──────────────────────────────────────────  execute workflow

PostgreSQL is the source of truth. Redis holds the warm window and provides the distributed execution lock. The hot wheel is an in-process DelayQueue holding only what is about to fire.

Task ids are i64 (PostgreSQL BIGSERIAL) rather than UUIDs, precisely so they can be used directly as DelayQueue keys.

Configuration

SettingDefaultDevelopmentProduction
hot_window_secs601060
warm_window_secs30060300
load_batch_size10020500
sweeper_interval_secs301030
stale_threshold_secs301530
lock_timeout_secs6030120
max_concurrency16464
heartbeat_interval_secs535
execution_timeout_secs30060300
crash_recovery_interval_secs602060

warm_window_secs must be greater than or equal to hot_window_secs; the config validates this rather than producing a gap tasks could fall into.

Reliability

Three mechanisms, each covering a different failure:

Claiming a task uses an optimistic lock; a conflicting claim fails with a lock conflict rather than double-executing.

Task lifecycle

 ┌─────────┐   claim()    ┌─────────┐
 │ Pending │ ──────────►  │ Running │
 └─────────┘              └─────────┘
      ▲                        │
      │ schedule_retry()  ┌────┴──────────────┐
      │                   ▼                   ▼
      │              ┌──────────┐        ┌────────┐
      └──────────────│ Pending  │        │ Failed │  (max retries exceeded)
                     └──────────┘        └────────┘
                       also: Completed, Cancelled, Paused

A recurring task that completes successfully is automatically rescheduled to its next cron time; a one-shot task ends at completed.

Recurring rules and timezones

{ "timezone": "Asia/Shanghai", "cron": "0 8 * * *" }

timezone is an IANA identifier and is required for recurring tasks. It is not decoration: the scheduler needs it to compute the next trigger correctly across DST transitions. "Daily at 08:00" is a different UTC instant in summer and winter, and a UTC-only cron gets it wrong twice a year.

The payload also carries params (handed to the workflow), an optional label, and the user_id / tenant_id / workspace_id scope that created the schedule.

The schedule node

tools/schedule manages scheduled tasks from inside a workflow — including from an agent, which is what makes "remind me every morning" work as a conversation.

Actions: list_tasks, get_task, get_execution_result, create_task, update_task, pause_task, resume_task, cancel_task, delete_task.

Modes: manual (the user fills the parameters) or llm (the model fills them).

run_at accepts three formats

FormatExampleResult
Relative delay2m, 30m, 1h, 1dRuns once after the wait
Cron expression0 9 * * *, 0 9 * * 1, 0 9 * * 0,6Recurring
Local wall-clock datetime2026-06-30T09:00Runs once at that time

The datetime form takes no timezone or offset — the node applies the server timezone itself. A one-off datetime must be in the future, and if it is not, the validation error states the current server time so the caller can correct it. That detail exists because a model asked to schedule something will otherwise guess today's date and get it wrong.

timezone is deliberately not an LLM-fillable field: the node uses the host's system timezone automatically, so the user states a wall-clock time and the model never has to pick a zone. It remains an optional manual override for the UI and other non-LLM callers.

frequency is the friendlier alternative to raw cron: once, every_minute, hourly, daily, weekly, monthly, or custom with custom_cron.

Input versus label — the mistake to avoid

Three fields are easy to confuse:

FieldIsIs not
task_textThe workflow input, as plain text — becomes raw_input for a chat-fronted workflowA name
paramsThe explicit input object, e.g. {"raw_input": "hello"} — exactly what the entry node receivesA name
labelA short descriptive name shown in lists and logsThe task content

Use task_text or params for the content. Always set a label, keep it under 20 characters, and make it descriptive — a task list of 任务1, test, 123 forces you to query every task by id to find the one you want.

Other options: priority (default 0), max_retries (default 3), target_node_id for routing task_text to a specific entry node, and limit (default 50, max 500) / offset / status for list_tasks.

REST surface

GET|POST  /schedules                    list, create
GET       /schedules/metrics            scheduler metrics
GET|PATCH|DELETE /schedules/:id         get, update, cancel (soft)
GET       /schedules/:id/executions     execution history for this task
DELETE    /schedules/:id/hard           hard delete
POST      /schedules/:id/pause
POST      /schedules/:id/resume

DELETE /schedules/:id cancels; /hard removes the row. Cron expressions are validated on create and update — an invalid expression is rejected there, not silently at the next tick.

Choosing a pattern

You wantDo this
One run later todayrun_at: "2h" or a local datetime
Every morning at 09:00run_at: "0 9 * * *" with a timezone
A user-created reminder from chatAn agent with tools/schedule in llm mode
Periodic re-indexingA schedule pointing at your indexing workflow — idempotent by version
Reaction to an external eventNot a schedule — use a channel

Next steps