# 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](/docs/chat-channel-routing)
arrives, another workflow calls it as a [subflow](/docs/editor-subflows), 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:

```text
       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 for
  `node_stale_threshold_secs` is considered dead.
- **Crash recovery** (leader-only) finds tasks stuck in `running` past
  `execution_timeout_secs` and 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

```text
 ┌─────────┐   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

```json
{ "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

```text
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](/docs/data-rag-pipeline) |
| Reaction to an external event | Not a schedule — use a [channel](/docs/chat-messaging-channels) |

## Next steps

- [Execution Model](/docs/exec-model) — what happens after a task fires.
- [Traces, Logs & Replay](/docs/exec-traces-logs) — inspecting a scheduled run.
- [Storage, Redis & Vector Configuration](/docs/config-storage-redis) — the Redis the scheduler needs.
