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
| Setting | Default | Development | Production |
|---|---|---|---|
hot_window_secs | 60 | 10 | 60 |
warm_window_secs | 300 | 60 | 300 |
load_batch_size | 100 | 20 | 500 |
sweeper_interval_secs | 30 | 10 | 30 |
stale_threshold_secs | 30 | 15 | 30 |
lock_timeout_secs | 60 | 30 | 120 |
max_concurrency | 16 | 4 | 64 |
heartbeat_interval_secs | 5 | 3 | 5 |
execution_timeout_secs | 300 | 60 | 300 |
crash_recovery_interval_secs | 60 | 20 | 60 |
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:
- The PG sweeper re-enqueues tasks that are overdue by more than
stale_threshold_secs— covering a lost Redis entry. - Node heartbeats every
heartbeat_interval_secs; a node silent fornode_stale_threshold_secsis considered dead. - Crash recovery (leader-only) finds tasks stuck in
runningpastexecution_timeout_secsand either retries them or fails them permanently.
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
| Format | Example | Result |
|---|---|---|
| Relative delay | 2m, 30m, 1h, 1d | Runs once after the wait |
| Cron expression | 0 9 * * *, 0 9 * * 1, 0 9 * * 0,6 | Recurring |
| Local wall-clock datetime | 2026-06-30T09:00 | Runs 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:
| Field | Is | Is not |
|---|---|---|
task_text | The workflow input, as plain text — becomes raw_input for a chat-fronted workflow | A name |
params | The explicit input object, e.g. {"raw_input": "hello"} — exactly what the entry node receives | A name |
label | A short descriptive name shown in lists and logs | The 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 want | Do this |
|---|---|
| One run later today | run_at: "2h" or a local datetime |
| Every morning at 09:00 | run_at: "0 9 * * *" with a timezone |
| A user-created reminder from chat | An agent with tools/schedule in llm mode |
| Periodic re-indexing | A schedule pointing at your indexing workflow — idempotent by version |
| Reaction to an external event | Not a schedule — use a channel |
Next steps
- Execution Model — what happens after a task fires.
- Traces, Logs & Replay — inspecting a scheduled run.
- Storage, Redis & Vector Configuration — the Redis the scheduler needs.